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; } }