Public Access
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
278 lines
12 KiB
C#
278 lines
12 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block
|
|
/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that
|
|
/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width
|
|
/// column holding a string of unbounded length beside a button that must stay clickable; the second is a
|
|
/// width the rest of the window has to cope with, and <c>MainWindow.axaml</c> copes with it by asking this
|
|
/// control how wide it is rather than by knowing.
|
|
/// </remarks>
|
|
public sealed class SessionSidebarTests : IAsyncLifetime
|
|
{
|
|
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
|
|
private const double OpenWidth = 300;
|
|
|
|
/// <inheritdoc cref="OpenWidth" />
|
|
private const double RailWidth = 34;
|
|
|
|
/// <remarks>
|
|
/// Long on purpose, and longer than the column is wide at this font: the address is the one string here
|
|
/// whose length nobody controls, and it shares its row with the button that closes the column.
|
|
/// </remarks>
|
|
private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
|
|
|
private string directory = null!;
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
// A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written
|
|
// through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths
|
|
// would be editing the preferences of whoever ran it.
|
|
directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}");
|
|
caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}");
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
new ClientPaths(directory),
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>())
|
|
{
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
|
|
/// <see cref="LayoutHarness.Unreachable"/> rather than by reading the address's own width, because what
|
|
/// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing
|
|
/// beside it was pushed out of the column to make room.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, window) =>
|
|
{
|
|
// DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on
|
|
// the Border inside it, so what the surrounding "Auto" column is given — and what this asks for
|
|
// — is what the control asks for, not how wide a host window happened to stretch it.
|
|
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
|
|
shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one");
|
|
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cross-surface button that used to live in the header row. Read off the control rather than off the
|
|
/// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still
|
|
/// draw as an empty button — fails this.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, _) =>
|
|
{
|
|
var button = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(candidate => candidate.Classes.Contains("headerghost"));
|
|
|
|
button.Content.ShouldBe("Open SFTP");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The address is the one string in this column somebody has to be able to take away with them, and the
|
|
/// claim under test is the half of that which the trimming could quietly break: an ellipsis is a drawing,
|
|
/// not a shorter string, so select-all reaches the whole address and not the part that happened to fit.
|
|
/// Asserted on the longest address this suite has — the case where the two could disagree — rather than
|
|
/// on one that fits, which would pass whether the trimming were render-only or not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheAddressSelectsAndCopiesWholeEvenWhereItIsTrimmed()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, _) =>
|
|
{
|
|
// Single rather than First: this column prints exactly one string that is there to be copied, and
|
|
// a second selectable one appearing beside it is a question this test should ask rather than skip.
|
|
var address = sidebar.GetVisualDescendants().OfType<SelectableTextBlock>().Single();
|
|
|
|
address.Text.ShouldBe(LongAddress);
|
|
|
|
address.SelectAll();
|
|
|
|
address.SelectedText.ShouldBe(LongAddress);
|
|
address.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
|
|
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
|
|
/// on it is the difference between a panel somebody closed and a panel somebody lost.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, window) =>
|
|
{
|
|
shell.IsSessionSidebarOpen = false;
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
|
|
|
|
var grip = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
|
|
|
|
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the grip is not in this window's tree");
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
|
|
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// FORWARDS at its widest: a local forward to a long name, listening, and one that could not start with
|
|
/// its reason wrapped under it. The state word sits at the end of the row beside a trimming notation,
|
|
/// which is the same shape as the address and its close button above — the word must not be pushed out.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheForwardsList_FitsWithALongTargetAndAFailureReason()
|
|
{
|
|
await OnTheSidebarAsync(
|
|
(_, window) => LayoutHarness.Unreachable(window).ShouldBeEmpty(),
|
|
tab =>
|
|
{
|
|
var listening = Forward(15432, "customer-a-production-database.eu-west-1.internal", refusal: null);
|
|
var refused = Forward(
|
|
8080, "web.internal", "The remote would not listen on port 8080. It may be in use there, below 1024, or forbidden by the server's AllowTcpForwarding setting.");
|
|
|
|
tab.ReplaceForwards([listening, refused]);
|
|
|
|
// Both starts complete synchronously against these stand-ins, so neither needs awaiting on a
|
|
// harness whose body is synchronous.
|
|
_ = listening.StartAsync();
|
|
_ = refused.StartAsync();
|
|
|
|
listening.IsListening.ShouldBeTrue();
|
|
refused.IsFailed.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
private static PortForwardViewModel Forward(int port, string target, string? refusal) =>
|
|
new(
|
|
new PortForwardRule
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
Kind = PortForwardKind.Local,
|
|
BindPort = port,
|
|
TargetHost = target,
|
|
TargetPort = port,
|
|
},
|
|
(spec, _) => refusal is null
|
|
? Task.FromResult(Substitute.For<ISshPortForward>())
|
|
: Task.FromException<ISshPortForward>(new SshPortForwardException(refusal)),
|
|
action => action());
|
|
|
|
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body, Action<TerminalTabViewModel> arrange) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
arrange(shell.Tabs[0]);
|
|
|
|
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true, ShowsForwards = true };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
try
|
|
{
|
|
body(sidebar, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
try
|
|
{
|
|
body(sidebar, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|