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:
laurensandClaude Opus 5.5 committed 2026-10-20 16:26:32 +02:00
commit 6f6e92f315
25 files changed
+2667 -417

No files matched your search

+405 -84
View File
@@ -1041,19 +1041,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// The one connection this application makes to a machine that is not in the keychain. What is worth
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
/// the port all come out of the one box.
/// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
/// them.
/// </remarks>
[Fact]
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = " deploy@build.internal:2222 ";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
@@ -1066,74 +1064,416 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
[Fact]
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("root@box", "hunter2");
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
}
/// <remarks>
/// <para>
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
/// rather than here as a sentence about the box that was typed into.
/// rather than here as a sentence about the line that was typed into.
/// </para>
/// <para>
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
/// address — which is why their sentences are the prompt's own and not the address parser's.
/// </para>
/// </remarks>
[Theory]
[InlineData("", "Type a machine")]
[InlineData("build.internal", "Say who to log in as")]
[InlineData("", "Type the name of a saved host")]
[InlineData("build.internal", "No saved host is called")]
[InlineData("deploy@", "Say who to log in as")]
[InlineData("@build.internal", "Say who to log in as")]
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
string typed,
string because)
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = typed;
vault.ManualPassword = "hunter2";
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = typed;
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await shell.SubmitPromptCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain(because);
shell.PromptRefusal.ShouldContain(because);
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
}
/// <remarks>
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
/// and for nothing else. A plain search must not grow one on every keystroke.
/// </remarks>
[Theory]
[InlineData("root@box", "root@box:22")]
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
string typed,
string offered)
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "prod";
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
vault.HasFilterAddressRefusal.ShouldBeFalse();
vault.HostFilter = typed;
vault.HasFilterAddress.ShouldBeTrue();
vault.FilterAddressText.ShouldBe(offered);
}
/// <remarks>
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
/// hold and the password does not wait in the box for the next one.
/// </remarks>
[Fact]
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
var hostsBefore = vault.Hosts.Count;
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.HostFilter = "deploy@build.internal:2222";
vault.FilterAddressPassword = "hunter2";
vault.ManualStatus.ShouldContain("password");
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
vault.HostFilter.ShouldBeEmpty();
vault.FilterAddressPassword.ShouldBeEmpty();
vault.HasFilterAddress.ShouldBeFalse();
}
/// <remarks>
/// An empty password is refused under the box rather than sent, as the connect prompt refuses one — and
/// the address stays where it was typed, so filling the password in is all that is left to do.
/// </remarks>
[Fact]
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.HostFilter = "root@box";
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
vault.FilterAddressRefusal.ShouldContain("password");
vault.HostFilter.ShouldBe("root@box");
ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty();
}
/// <remarks>
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
/// rather than offering nothing and leaving the box looking ignored.
/// </remarks>
[Fact]
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box:70000";
vault.HasFilterAddress.ShouldBeFalse();
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
}
// ---- The empty terminal's prompt ----
/// <remarks>
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
/// </remarks>
[Fact]
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
shell.ShowTerminalCommand.Execute(null);
shell.IsConnectPromptShowing.ShouldBeTrue();
shell.PromptText = "deploy@build.internal";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.HasTabs.ShouldBeTrue();
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
}
/// <remarks>
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
/// prompt asks for it in ssh's own words and dials the host with it.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "PROD-DB";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
shell.PromptText.ShouldBeEmpty();
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
shell.IsPromptAskingForPassword.ShouldBeFalse();
}
/// <remarks>
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
/// at a prompt that never offered to save anything.
/// </remarks>
[Fact]
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.RemembersConnectPassword = true;
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod-db";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem();
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
}
/// <remarks>
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
/// is left exactly as it was.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "deploy@build.internal:2222";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
vault.Credentials.ShouldBeEmpty();
}
/// <remarks>
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
/// names the nearest host, and Tab takes it.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("Tab");
ssh.Requests.ShouldBeEmpty();
shell.CompletePrompt().ShouldBeTrue();
shell.PromptText.ShouldBe("prod-db");
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
}
/// <remarks>
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
/// </remarks>
[Fact]
public async Task ATappedMatchOnThePrompt_TakesThatHost()
{
var vault = await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
shell.PromptText.ShouldBe("prod-db");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
/// it refuses rather than guessing and points at the palette, which does say.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesANameTwoHostsShare()
{
var vault = await ReadyToConnectAsync();
await AddHostAsync(vault, "prod-db");
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod-db";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("More than one");
shell.IsPromptAskingForPassword.ShouldBeFalse();
}
/// <remarks>
/// Escape walks back one line at a time, as it does in the palette: off the password onto the name with
/// the name intact, and then the name cleared.
/// </remarks>
[Fact]
public async Task EscapeAtThePrompt_StepsBackALineAtATime()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "root@box";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "hunter2";
shell.CancelPromptCommand.Execute(null);
shell.IsPromptAskingForPassword.ShouldBeFalse();
shell.PromptText.ShouldBe("root@box");
shell.PromptPassword.ShouldBeEmpty();
shell.CancelPromptCommand.Execute(null);
shell.PromptText.ShouldBeEmpty();
}
/// <remarks>
/// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
/// be a sentence about the machine when the problem is the box. A password is the only thing a typed
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
/// copy of.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAnEmptyPassword()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "root@box";
await shell.SubmitPromptCommand.ExecuteAsync(null);
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("password");
shell.IsPromptAskingForPassword.ShouldBeTrue();
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
/// A recent connection that names no keychain host is the address that was dialled, so choosing it asks
/// for that address's password — the log never held the password, which is the point of the path.
/// </remarks>
[Fact]
public async Task ARecentAddressOnThePrompt_AsksForItsPassword()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
await shell.ChoosePromptRecentCommand.ExecuteAsync(
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
shell.PromptText.ShouldBe("deploy@build.internal:2222");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword.ShouldBeEmpty("the log never held one");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
/// away, or the next machine typed would be dialled with somebody else's password.
/// </remarks>
[Fact]
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box";
vault.FilterAddressPassword = "hunter2";
vault.HostFilter = "root@other-box";
vault.FilterAddressPassword.ShouldBeEmpty();
}
/// <remarks>
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
/// the only way to connect; with a typed address it would answer "do you trust this key" by dialling a
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
/// the assertion would still catch it.
@@ -1150,10 +1490,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.HasPendingHostKey.ShouldBeTrue();
@@ -1168,46 +1505,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
/// is where its key, its password box and its refusals already live.
/// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
/// keychain half takes the host as typing its name would, so its key, its saved password and its
/// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
/// with nothing dialled yet.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
public async Task ARecentHostOnThePrompt_TakesThatHost()
{
var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0];
vault.SelectedHost = null;
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
shell.IsHostsShowing.ShouldBeTrue();
vault.SelectedHost.ShouldBe(host);
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
}
/// <remarks>
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
/// so it goes straight back in — without the password, which was never stored and whose absence is the
/// point of that path rather than a gap in it.
/// </remarks>
[Fact]
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
{
var vault = await ReadyToConnectAsync();
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
// screen the application happens to open on.
shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute(
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
vault.ManualPassword.ShouldBeEmpty();
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
@@ -1215,17 +1531,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// about it, so the address is the honest answer rather than a tap that does nothing.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute(
await shell.ChoosePromptRecentCommand.ExecuteAsync(
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
vault.ManualTarget.ShouldBe("root@prod-db:22");
shell.IsTerminalSurface.ShouldBeTrue();
shell.PromptText.ShouldBe("root@prod-db:22");
shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
ssh.Requests.ShouldBeEmpty();
}
/// <summary>One row of the connection log, built by hand.</summary>
@@ -3247,8 +3564,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
/// and what the user comes back to whichever way they answer.
/// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
/// what the user comes back to whichever way they answer.
/// </remarks>
[Fact]
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
@@ -3257,19 +3574,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.HasPendingHostKey.ShouldBeTrue();
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
}
@@ -8240,14 +8552,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
[Fact]
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
@@ -11047,6 +11356,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
return vault;
}
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
private async Task ConnectAtThePromptAsync(string typed, string password)
{
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = typed;
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = password;
await shell.SubmitPromptCommand.ExecuteAsync(null);
}
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
private async Task<FakeSftpSession> ConnectedTransfersAsync()
{