Public Access
Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no
This commit is contained in:
1 parent
372a7cbdc7
commit
6c09bdecb6
11 files changed
+498
-92
No files matched your search
@@ -1,16 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
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.Client.Transfer;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
@@ -24,18 +18,11 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
||||
/// and Enter in the server address did nothing at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The whole window, which <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says cannot be shown
|
||||
/// here: the terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the
|
||||
/// tree before the window is shown, which is everything that test is about and nothing these are.
|
||||
/// The whole window, shown the way <see cref="WholeWindow"/> describes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClosedVaultFocusTests
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
@@ -44,14 +31,14 @@ public sealed class ClosedVaultFocusTests
|
||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await using var workspace = Workspace();
|
||||
await using var shell = Shell(caches, workspace);
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WithWindow(shell, window =>
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
|
||||
|
||||
@@ -69,59 +56,19 @@ public sealed class ClosedVaultFocusTests
|
||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync("https://dodossh.example", Passphrase, "laptop", "Personal", Token);
|
||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
await using var workspace = Workspace();
|
||||
await using var shell = Shell(caches, workspace);
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
shell.State.ShouldBe(ShellState.Locked);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WithWindow(shell, window =>
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
window.FocusManager!.GetFocusedElement()
|
||||
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
|
||||
Token);
|
||||
}
|
||||
|
||||
private static TerminalWorkspace Workspace() =>
|
||||
new(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
private static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
||||
new(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
new VaultKnownHostStore(),
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
private static void WithWindow(MainWindowViewModel shell, Action<MainWindow> assert)
|
||||
{
|
||||
var window = new MainWindow { DataContext = shell };
|
||||
|
||||
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
||||
{
|
||||
panel.Children.Remove(web);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
assert(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
|
||||
/// answers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They used to appear with the keyboard wherever it had been, Escape did nothing, and Tab walked the page
|
||||
/// behind them. The answer they are given here is always the harmless one — see <see cref="SafeAnswerFocus"/>
|
||||
/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
|
||||
/// </remarks>
|
||||
public sealed class ConfirmationKeyboardTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task TheSignOutQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
shell.SignOutCommand.Execute(null);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.GetVisualDescendants().OfType<SignOutCard>().Single(card => card.IsEffectivelyVisible);
|
||||
|
||||
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
shell.IsConfirmingSignOut.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnknownHostKey_TakesTheKeyboardOnCancel_KeepsTabInside_AndEscapeRefuses()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
vault.PendingHostKey = new HostKeyPresentation("web.example", 22, "ssh-ed25519", "SHA256:abc");
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.FindControl<HostKeyCard>("HostKeyPane").ShouldNotBeNull();
|
||||
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<Button>();
|
||||
|
||||
focused.Content.ShouldBe("CANCEL");
|
||||
|
||||
for (var press = 0; press < 4; press++)
|
||||
{
|
||||
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||
|
||||
window.FocusManager.GetFocusedElement().ShouldBeAssignableTo<Visual>()
|
||||
.ShouldNotBeNull().GetVisualAncestors().ShouldContain(card);
|
||||
}
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADeletionQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Tag, Guid.CreateVersion7(), "Delete the tag prod?", "It goes.", string.Empty);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.GetVisualDescendants().OfType<ConfirmDeleteCard>().Single(card => card.IsEffectivelyVisible);
|
||||
|
||||
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsConfirmingDeletion.ShouldBeFalse();
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscapeOnTheHostsScreen_PutsTheDrawerAway_LikeItsOwnArrow()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.IsDrawerOpen.ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsDrawerOpen.ShouldBeFalse();
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
private static async Task<ClientCacheFactory> EnrolledAsync()
|
||||
{
|
||||
var caches = ClientCacheFactory.ForMemory($"confirm-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
return caches;
|
||||
}
|
||||
|
||||
private static async Task<MainWindowViewModel> UnlockedAsync(ClientCacheFactory caches, TerminalWorkspace workspace)
|
||||
{
|
||||
var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
|
||||
shell.Passphrase = WholeWindow.Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsUnlocked.ShouldBeTrue();
|
||||
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The real main window over a real shell, for the tests that are about where the keyboard goes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says this window cannot be shown here: the
|
||||
/// terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the tree before
|
||||
/// the window is shown, which is everything that test is about and nothing these are.
|
||||
/// </remarks>
|
||||
internal static class WholeWindow
|
||||
{
|
||||
internal const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
internal static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
internal static TerminalWorkspace Workspace() =>
|
||||
new(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
internal static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
||||
new(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
new VaultKnownHostStore(),
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
internal static void Show(MainWindowViewModel shell, Action<MainWindow> assert)
|
||||
{
|
||||
var window = new MainWindow { DataContext = shell };
|
||||
|
||||
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
||||
{
|
||||
panel.Children.Remove(web);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
assert(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user