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;
///
/// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
///
///
/// What each key decides is ShellFlowTests' business; this is the half only a real control can show —
/// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose
/// password line appeared without the caret on it would take the password into the name it had just read.
/// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked
/// vault, and nothing that reaches a network.
///
public sealed class ConnectPromptTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// Far below the shipped profile: nothing here attacks a wrap.
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;
///
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"prompt-{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(StringComparer.Ordinal)),
Substitute.For(),
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(),
(_, _) => 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(),
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,
};
}
///
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task ThePromptTakesTheKeyboardWhenItAppears()
{
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
}
///
/// Enter on an address moves the question to the password, and the caret with it — typing straight on is
/// what somebody does next, and it has to land in the password box rather than on the end of the address.
///
[Fact]
public async Task EnterOnAnAddressPutsTheCaretOnThePassword()
{
await OnThePromptAsync((prompt, window) =>
{
shell.PromptText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.IsPromptAskingForPassword.ShouldBeTrue();
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
});
}
/// Escape off the password line puts the caret back on the name, which is still there.
[Fact]
public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName()
{
await OnThePromptAsync((prompt, window) =>
{
shell.PromptText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
Relayout(window);
shell.IsPromptAskingForPassword.ShouldBeFalse();
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse();
prompt.TargetBox.IsFocused.ShouldBeTrue();
shell.PromptText.ShouldBe("root@10.0.0.5");
});
}
///
/// Tab completes rather than moving the focus away, when there is something to complete to — and leaves
/// the caret at the end of what it wrote, so Enter is the next key rather than End.
///
[Fact]
public async Task TabCompletesTheNameAndKeepsTheCaretOnIt()
{
await OnThePromptAsync((prompt, window) =>
{
shell.PromptText = "host-3";
shell.PromptMatches.ShouldNotBeEmpty();
shell.PromptText = "ost-3";
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
shell.PromptText.ShouldBe("host-3");
prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on");
prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length);
});
}
// ---- Helpers ----
/// Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.
private Task OnThePromptAsync(Action body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.ShowTerminalCommand.Execute(null);
shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal");
var prompt = new ConnectPrompt { DataContext = shell };
var window = new Window { Content = prompt };
LayoutHarness.Settle(window, 900, 600);
// The posted focus the application's dispatcher would run once the prompt was laid out.
Dispatcher.UIThread.RunJobs();
try
{
body(prompt, window);
}
finally
{
window.Close();
}
},
Token);
/// Runs the layout pass, and the focus it posts, that the application's dispatcher would.
private static void Relayout(Window window)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
}
/// Enough hosts that a partial name has more than one to match.
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);
}
}