Public Access
Stop a reconnect dialling a different machine of the same name #17
No files matched your search
@@ -3761,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
AdoptTab(tab);
|
AdoptTab(tab);
|
||||||
@@ -3825,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
{
|
{
|
||||||
|
HostId = e.HostId,
|
||||||
StartedAt = clock.GetUtcNow(),
|
StartedAt = clock.GetUtcNow(),
|
||||||
Cipher = NullIfEmpty(e.Cipher),
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
@@ -4036,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
/// The host this tab was opened against, or null when the keychain no longer holds it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
|
||||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
|
||||||
/// still mean anything after a lock.
|
/// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
|
||||||
|
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
|
||||||
|
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
|
||||||
|
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id is the item's own
|
||||||
|
/// sync identity rather than a reference to a row object, so a vault that is locked and opened again
|
||||||
|
/// rebuilds its list around the same ids and a tab from before the lock finds its host in it. A host
|
||||||
|
/// renamed since the tab opened is found too.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||||
@@ -4051,10 +4061,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||||
tab is null || Vault is not { } vault
|
tab?.HostId is not { } hostId || Vault is not { } vault
|
||||||
? null
|
? null
|
||||||
: vault.Hosts.FirstOrDefault(
|
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
|
||||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the selected tab has stopped and there is something to dial again.
|
/// Whether the selected tab has stopped and there is something to dial again.
|
||||||
@@ -4382,10 +4391,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
|
||||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
|
||||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
|
||||||
/// wrong than a dot that goes dark when the vault is reopened.
|
/// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
|
||||||
|
/// offers, which would then be another host's folders.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
|
||||||
|
/// correct: there is no row here that it is a session on.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||||
@@ -4408,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
foreach (var host in vault.Hosts)
|
foreach (var host in vault.Hosts)
|
||||||
{
|
{
|
||||||
host.IsConnected = Tabs.Any(
|
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
|
||||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||||
if (host.IsConnected
|
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
|
||||||
&& SelectedTab is { } selected
|
|
||||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
connectedActiveHost = host;
|
connectedActiveHost = host;
|
||||||
}
|
}
|
||||||
@@ -4549,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
/// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
|
||||||
|
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
|
||||||
|
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenPinnedPathAsync(string path)
|
private async Task OpenPinnedPathAsync(string path)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4638,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void PinFolderFromSidebar()
|
private void PinFolderFromSidebar()
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4674,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(tab) is not { } host)
|
||||||
|| tab is null
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
|
|
||||||
internal string Label { get; }
|
internal string Label { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
|
||||||
|
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
|
||||||
|
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
|
||||||
|
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
|
||||||
|
/// the one place being approximately right is dialling a stranger. See
|
||||||
|
/// <c>MainWindowViewModel.HostFor</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
|
||||||
|
/// an index into the list on screen — see <c>VaultItem<TSecret>.EntityId</c> — so a vault that is
|
||||||
|
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
|
||||||
|
/// finds its host again. A rename finds it too, which a name match by definition cannot.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
|
||||||
|
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid? HostId { get; init; }
|
||||||
|
|
||||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
internal string Address { get; }
|
internal string Address { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -947,6 +947,7 @@ internal static class ItemBadge
|
|||||||
/// <param name="identityLabel">
|
/// <param name="identityLabel">
|
||||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||||||
internal sealed class TerminalSessionEventArgs(
|
internal sealed class TerminalSessionEventArgs(
|
||||||
Guid attemptId,
|
Guid attemptId,
|
||||||
uint sessionId,
|
uint sessionId,
|
||||||
@@ -954,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
string address,
|
string address,
|
||||||
string cipher,
|
string cipher,
|
||||||
string hostKeyAlgorithm,
|
string hostKeyAlgorithm,
|
||||||
string? identityLabel) : EventArgs
|
string? identityLabel,
|
||||||
|
Guid? hostId) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Which attempt this session came out of.</summary>
|
/// <summary>Which attempt this session came out of.</summary>
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||||
@@ -971,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||||
|
|
||||||
internal string? IdentityLabel { get; } = identityLabel;
|
internal string? IdentityLabel { get; } = identityLabel;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
|
||||||
|
internal Guid? HostId { get; } = hostId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||||||
/// <param name="label">The host's name, as the vault has it.</param>
|
/// <param name="label">The host's name, as the vault has it.</param>
|
||||||
/// <param name="address">Who this will be logged in as, and where.</param>
|
/// <param name="address">Who this will be logged in as, and where.</param>
|
||||||
|
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||||||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||||||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||||||
/// has no vault to read it from.
|
/// has no vault to read it from.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
internal sealed class ConnectionAttemptEventArgs(
|
||||||
|
Guid attemptId,
|
||||||
|
string label,
|
||||||
|
string address,
|
||||||
|
Guid? hostId) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -995,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
|||||||
internal string Label { get; } = label;
|
internal string Label { get; } = label;
|
||||||
|
|
||||||
internal string Address { get; } = address;
|
internal string Address { get; } = address;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
|
||||||
|
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
|
||||||
|
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
|
||||||
|
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
|
||||||
|
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
|
||||||
|
/// see <c>TerminalTabViewModel.HostId</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid? HostId { get; } = hostId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has got as far as a named step.</summary>
|
/// <summary>A connection that has got as far as a named step.</summary>
|
||||||
@@ -11326,7 +11349,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
var attempt = new ConnectionAttemptEventArgs(
|
var attempt = new ConnectionAttemptEventArgs(
|
||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
target.Label,
|
target.Label,
|
||||||
Dialled(target, authentication));
|
Dialled(target, authentication),
|
||||||
|
target.HostId);
|
||||||
|
|
||||||
ConnectionStarting?.Invoke(this, attempt);
|
ConnectionStarting?.Invoke(this, attempt);
|
||||||
Status = $"Connecting to {target.Label}…";
|
Status = $"Connecting to {target.Label}…";
|
||||||
@@ -11784,7 +11808,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
Dialled(target, authentication),
|
Dialled(target, authentication),
|
||||||
facts?.Cipher ?? string.Empty,
|
facts?.Cipher ?? string.Empty,
|
||||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||||
authentication.IdentityLabel));
|
authentication.IdentityLabel,
|
||||||
|
target.HostId));
|
||||||
|
|
||||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||||
// for must not wait on a vault write to appear.
|
// for must not wait on a vault write to appear.
|
||||||
|
|||||||
@@ -2099,6 +2099,82 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
"the session that was replaced should have been released with its pane");
|
"the session that was replaced should have been released with its pane");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE ONE THIS EXISTS FOR: a reconnect that dialled a different machine of the same name. Nothing in
|
||||||
|
/// this application stops two hosts being called the same thing — a team's vault beside a personal one
|
||||||
|
/// makes it ordinary, and "prod-db" is what a database server gets called in both — and a tab used to
|
||||||
|
/// find its host by that name. A dropped session on one machine then came back as a live shell on the
|
||||||
|
/// other, with the tab, the address and the status bar all still naming the first.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted on the address actually dialled rather than on anything the tab says about itself. The two
|
||||||
|
/// rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written
|
||||||
|
/// against the label would have passed on the day the wrong machine was being opened.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingATabWhoseHostHasANamesake_DialsTheMachineTheTabWasOpenedOn()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
|
// A second host under the name the fixture's own already has, on a different machine.
|
||||||
|
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||||
|
|
||||||
|
// Both bound to the key, so neither is diverted to the password sheet: what this asserts is which of
|
||||||
|
// the two gets dialled, not whether either can be. Re-found between the two, because saving a host
|
||||||
|
// rebuilds the list.
|
||||||
|
await BindKeyAsync(vault, Machine(vault, "db.internal"), vault.Keys[0].EntityId);
|
||||||
|
await BindKeyAsync(vault, Machine(vault, "db-replica.internal"), vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectToRowCommand.ExecuteAsync(Machine(vault, "db-replica.internal"));
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
ssh.Requests.ShouldHaveSingleItem().Host.ShouldBe("db-replica.internal");
|
||||||
|
|
||||||
|
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
shell.CanReconnectSelectedTab.ShouldBeTrue("the machine this tab was opened on is still there");
|
||||||
|
|
||||||
|
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
tab.IsLive.ShouldBeTrue(tab.Status);
|
||||||
|
|
||||||
|
ssh.Requests.Count.ShouldBe(2);
|
||||||
|
ssh.Requests[1].Host.ShouldBe(
|
||||||
|
"db-replica.internal",
|
||||||
|
"a reconnect dials the machine the tab was opened on, not the one that shares its name");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same mix-up as the test above, where it is read rather than dialled. A green dot is the host list
|
||||||
|
/// answering "am I on this machine", and the name two rows share is not an answer to it — a dot on the
|
||||||
|
/// machine nobody has a shell on claims a session that does not exist, and takes QUICK ACCESS's pins with
|
||||||
|
/// it, since the sidebar offers the pinned folders of whichever row that same match found.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingToOneOfTwoHostsOfTheSameName_LightsThatOneAlone()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.SelectedHost = Machine(vault, "db-replica.internal");
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue();
|
||||||
|
|
||||||
|
Machine(vault, "db-replica.internal").IsConnected.ShouldBeTrue();
|
||||||
|
Machine(vault, "db.internal").IsConnected.ShouldBeFalse(
|
||||||
|
"nobody has a shell on this machine; only its name was connected to");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
||||||
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
||||||
@@ -10377,11 +10453,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
private static async Task AddHostAsync(
|
||||||
|
VaultViewModel vault,
|
||||||
|
string label,
|
||||||
|
string hostname = "db.internal")
|
||||||
{
|
{
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = label;
|
vault.EditorLabel = label;
|
||||||
vault.EditorHostname = "db.internal";
|
vault.EditorHostname = hostname;
|
||||||
vault.EditorUsername = "deploy";
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
@@ -10405,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>The host row for a given machine, for the tests whose rows deliberately share a name.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Host"/> cannot be asked when two rows are called the same thing, which is the whole
|
||||||
|
/// situation those tests set up. Re-found rather than held, because saving any host rebuilds the list.
|
||||||
|
/// </remarks>
|
||||||
|
private static HostRowViewModel Machine(VaultViewModel vault, string hostname) =>
|
||||||
|
vault.Hosts.Single(row => string.Equals(row.Host.Hostname, hostname, StringComparison.Ordinal));
|
||||||
|
|
||||||
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||||
|
|||||||
Reference in new issue
Block a user