using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Client.Ssh.Tests;
///
/// The probe that runs after a connection has failed, and the four answers it can come back with.
///
///
///
/// Real sockets on loopback rather than an abstraction over them, because the thing under test is
/// what a socket does: the whole reason this type exists is that a refusal and a silence are
/// indistinguishable in the exception SSH.NET throws and completely distinguishable to a TCP connect. A fake
/// socket would be this suite asserting that the mapping table maps, and would have passed just as happily
/// against the bug that prompted the work.
///
///
/// No container and no sshd. Nothing here speaks SSH — the probe deliberately does not — so the fixture is a
/// listener that accepts, a port nothing is on, and a name that does not resolve.
///
///
public sealed class SshReachabilityProbeTests
{
///
/// Short, because two of these cases prove a negative by waiting. The production budget is six seconds —
/// see — and nothing about the branch under test changes with
/// the number, so the suite buys the same coverage for a fraction of the wall clock.
///
private static readonly TimeSpan ShortBudget = TimeSpan.FromSeconds(2);
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
/// The case the whole type was written for. SSH.NET reports this and with
/// the identical "Connection has timed out." — so if this comes back as anything but
/// , the probe is adding nothing the exception did not already say.
///
[Fact]
public async Task APortThatAcceptsIsOpen_WhichIsWhatTellsAWedgedSshdFromABrokenNetwork()
{
using var listener = Accepting();
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
report.Reach.ShouldBe(SshReach.Open);
report.Address.ShouldBe("127.0.0.1");
report.Summary.ShouldContain("handshake");
// The half a user acts on: nothing here may suggest the network is at fault, because it demonstrably
// is not — a socket just opened over it.
report.Summary.ShouldNotContain("firewall");
}
///
///
/// A port with nothing on it. The distinction from is the entire value of
/// the finding: refused means the machine is up.
///
///
/// ◆ The full budget, and this test is why the production one is six seconds. A refusal is not
/// immediate on Windows — the stack retries the SYN, and a closed loopback port took ~2s to answer when
/// this was written. Run against this case comes back
/// , which is the probe telling somebody a machine that is up and
/// refusing is behind a firewall. Left at the real budget on purpose: it is the one case whose
/// correctness depends on the number, so it is the one that should fail if the number is lowered.
///
///
[Fact]
public async Task NothingListening()
{
var report = await new SshReachabilityProbe(SshReachabilityProbe.Budget)
.ProbeAsync("127.0.0.1", UnusedPort(), Token);
report.Reach.ShouldBe(SshReach.Refused);
report.Summary.ShouldContain("sshd is not running");
}
///
///
/// 192.0.2.1 is TEST-NET-1 from RFC 5737: reserved for documentation, routed nowhere, and guaranteed not
/// to be some real machine that answers and makes this flap. The packets go out and nothing comes back,
/// which is exactly the shape of a firewall that drops rather than rejects.
///
///
/// The elapsed assertion is the one that matters beside the enum. Silence is only evidence if the probe
/// actually waited for it, and a bug that returned instantly — a socket
/// erroring for some unrelated reason — would otherwise pass this while telling the user something false
/// about their firewall.
///
///
[Fact]
public async Task NothingAnswers()
{
var report = await new SshReachabilityProbe(ShortBudget).ProbeAsync("192.0.2.1", 22, Token);
report.Reach.ShouldBe(SshReach.NoAnswer);
report.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(1));
report.Summary.ShouldContain("firewall");
}
///
/// .invalid is reserved by RFC 2606 precisely so that a test may rely on it never resolving, on
/// any network, including one whose resolver answers every NXDOMAIN with a search page.
///
[Fact]
public async Task ANameThatDoesNotResolve()
{
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("dodossh-no-such-host.invalid", 22, Token);
report.Reach.ShouldBe(SshReach.Unresolved);
report.Address.ShouldBeNull("there is no address, and inventing one would be the whole failure");
report.Summary.ShouldContain("does not resolve");
}
///
/// An address typed in is not looked up, and the report must not read as though it had been. The
/// assertion is on the sentence rather than on a call count: what would be wrong is the user being shown
/// "127.0.0.1 (127.0.0.1)", which is what a probe that resolved a literal and then printed both produces.
///
[Fact]
public async Task AnAddressIsNotPrintedTwice()
{
using var listener = Accepting();
var report = await new SshReachabilityProbe(ShortBudget)
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
report.Summary.ShouldNotContain("(127.0.0.1)");
}
///
/// The caller is in a failure path with nothing to do with a second exception — see
/// — so every branch above returns a report. This is the
/// one input that is a caller's mistake rather than a broken host, and it is the one that still throws.
///
[Fact]
public async Task AnEmptyHostIsTheCallersBug_NotAFinding()
{
await Should.ThrowAsync(
async () => await new SshReachabilityProbe(ShortBudget).ProbeAsync(" ", 22, Token));
}
private static TcpListener Accepting()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return listener;
}
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
/// A port that was free a moment ago, which is the closest thing to a guarantee there is.
///
/// Bound and released rather than picked out of the air: the OS hands out a port it knows is unused, and
/// the window in which something else could take it is far smaller than the odds of a hard-coded number
/// colliding with whatever the machine running this happens to have open.
///
private static int UnusedPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = PortOf(listener);
listener.Stop();
return port;
}
}