Public Access
Merge origin/main into claude/spelunk-api-key-tenant-6373cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6f6e92f315
25 files changed
+2667
-417
No files matched your search
@@ -0,0 +1,222 @@
|
||||
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 empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What each key decides is <c>ShellFlowTests</c>' 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.
|
||||
/// </remarks>
|
||||
public sealed class ConnectPromptTests : 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($"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<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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePromptTakesTheKeyboardWhenItAppears()
|
||||
{
|
||||
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>Escape off the password line puts the caret back on the name, which is still there.</remarks>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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 ----
|
||||
|
||||
/// <summary>Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.</summary>
|
||||
private Task OnThePromptAsync(Action<ConnectPrompt, Window> 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);
|
||||
|
||||
/// <summary>Runs the layout pass, and the focus it posts, that the application's dispatcher would.</summary>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
/// <remarks>Enough hosts that a partial name has more than one to match.</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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
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>
|
||||
|
||||
Reference in new issue
Block a user