NetworkScannerTests.cs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources.Services.Networking;
  3. namespace Tests.Discovery;
  4. /// <summary>
  5. /// The sweep's decisions, driven through a scripted probe: what counts as alive,
  6. /// what IO happens for dead hosts, and that the concurrency cap actually caps.
  7. /// The probe is the IO seam — everything above it is what these tests own.
  8. /// </summary>
  9. public class NetworkScannerTests {
  10. private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
  11. new() {
  12. Cidr = Cidr.Parse(cidr),
  13. Ports = ports.Length > 0 ? ports : [22, 80],
  14. PingTimeout = TimeSpan.FromMilliseconds(5),
  15. PortTimeout = TimeSpan.FromMilliseconds(5)
  16. };
  17. [Fact]
  18. public async Task A_host_that_answers_nothing_is_not_reported() {
  19. var probe = new ScriptedProbe();
  20. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  21. Assert.Empty(hosts);
  22. }
  23. [Fact]
  24. public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() {
  25. var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
  26. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  27. NetworkHostFact host = Assert.Single(hosts);
  28. Assert.Equal("10.0.0.1", host.Ip);
  29. Assert.True(host.AnsweredPing);
  30. // Liveness is already proven; knocking on ports would just be noise on the wire.
  31. Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1");
  32. }
  33. [Fact]
  34. public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() {
  35. var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] };
  36. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  37. NetworkHostFact host = Assert.Single(hosts);
  38. Assert.Equal("10.0.0.2", host.Ip);
  39. Assert.False(host.AnsweredPing);
  40. Assert.Equal([80], host.OpenPorts);
  41. }
  42. [Fact]
  43. public async Task Port_probing_stops_at_the_first_answer() {
  44. var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
  45. await NetworkScanner.ScanAsync(probe, Options());
  46. // 22 answered, so 80 was never asked: the sweep proves liveness, not a port map.
  47. Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2"));
  48. }
  49. [Fact]
  50. public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() {
  51. var probe = new ScriptedProbe {
  52. PingReplies = ["10.0.0.1"],
  53. Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]",
  54. Names = { ["10.0.0.1"] = "router.lan" }
  55. };
  56. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  57. Assert.True(probe.ArpReadAfterSweep,
  58. "ARP must be read after the sweep — the sweep's own probes populate it.");
  59. Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac);
  60. Assert.Equal("router.lan", hosts[0].Hostname);
  61. Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries
  62. }
  63. [Fact]
  64. public async Task Results_come_back_in_address_order_whatever_order_probes_finished() {
  65. var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] };
  66. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  67. Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip));
  68. }
  69. [Fact]
  70. public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() {
  71. var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) };
  72. NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 };
  73. await NetworkScanner.ScanAsync(probe, options);
  74. Assert.True(probe.MaxInFlight <= 4,
  75. $"{probe.MaxInFlight} hosts were probed at once; the cap was 4.");
  76. }
  77. [Fact]
  78. public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() {
  79. // The floor lives in the scanner, not a front end: an auto-detected VPN /10
  80. // must hit the same wall a typed --cidr does.
  81. await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
  82. NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
  83. }
  84. /// <summary>Scripted IO: answers what it is told to, records what was asked of it.</summary>
  85. private sealed class ScriptedProbe : INetworkProbe {
  86. private readonly Lock _lock = new();
  87. private int _inFlight;
  88. private bool _sweepDone;
  89. public List<string> PingReplies { get; init; } = [];
  90. public List<(string Ip, int Port)> OpenPorts { get; init; } = [];
  91. public string? Arp { get; init; }
  92. public Dictionary<string, string> Names { get; } = [];
  93. public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
  94. public List<(string Ip, int Port)> PortProbes { get; } = [];
  95. public List<string> DnsLookups { get; } = [];
  96. public bool IsSupported => true;
  97. public int MaxInFlight { get; private set; }
  98. public bool ArpReadAfterSweep { get; private set; }
  99. public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
  100. lock (_lock) {
  101. _inFlight++;
  102. MaxInFlight = Math.Max(MaxInFlight, _inFlight);
  103. }
  104. try {
  105. if (PingDelay > TimeSpan.Zero)
  106. await Task.Delay(PingDelay, cancellationToken);
  107. return PingReplies.Contains(ip);
  108. }
  109. finally {
  110. lock (_lock) {
  111. _inFlight--;
  112. }
  113. }
  114. }
  115. public Task<bool> TryConnectAsync(
  116. string ip,
  117. int port,
  118. TimeSpan timeout,
  119. CancellationToken cancellationToken = default) {
  120. lock (_lock) {
  121. PortProbes.Add((ip, port));
  122. }
  123. return Task.FromResult(OpenPorts.Contains((ip, port)));
  124. }
  125. public Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
  126. lock (_lock) {
  127. _sweepDone = true;
  128. ArpReadAfterSweep = _inFlight == 0;
  129. }
  130. return Task.FromResult(Arp);
  131. }
  132. public Task<string?> ReverseDnsAsync(
  133. string ip,
  134. TimeSpan timeout,
  135. CancellationToken cancellationToken = default) {
  136. lock (_lock) {
  137. if (!_sweepDone)
  138. throw new InvalidOperationException("Reverse DNS ran before the sweep finished.");
  139. DnsLookups.Add(ip);
  140. }
  141. return Task.FromResult(Names.GetValueOrDefault(ip));
  142. }
  143. public Cidr? LocalSubnet() => null;
  144. }
  145. }