using RackPeek.Domain.Discovery; using RackPeek.Domain.Resources.Services.Networking; namespace Tests.Discovery; /// /// The sweep's decisions, driven through a scripted probe: what counts as alive, /// what IO happens for dead hosts, and that the concurrency cap actually caps. /// The probe is the IO seam — everything above it is what these tests own. /// public class NetworkScannerTests { private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) => new() { Cidr = Cidr.Parse(cidr), Ports = ports.Length > 0 ? ports : [22, 80], PingTimeout = TimeSpan.FromMilliseconds(5), PortTimeout = TimeSpan.FromMilliseconds(5) }; [Fact] public async Task A_host_that_answers_nothing_is_not_reported() { var probe = new ScriptedProbe(); IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); Assert.Empty(hosts); } [Fact] public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() { var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); NetworkHostFact host = Assert.Single(hosts); Assert.Equal("10.0.0.1", host.Ip); Assert.True(host.AnsweredPing); // Liveness is already proven; knocking on ports would just be noise on the wire. Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1"); } [Fact] public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() { var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] }; IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); NetworkHostFact host = Assert.Single(hosts); Assert.Equal("10.0.0.2", host.Ip); Assert.False(host.AnsweredPing); Assert.Equal([80], host.OpenPorts); } [Fact] public async Task Port_probing_stops_at_the_first_answer() { var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] }; await NetworkScanner.ScanAsync(probe, Options()); // 22 answered, so 80 was never asked: the sweep proves liveness, not a port map. Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2")); } [Fact] public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() { var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"], Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]", Names = { ["10.0.0.1"] = "router.lan" } }; IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); Assert.True(probe.ArpReadAfterSweep, "ARP must be read after the sweep — the sweep's own probes populate it."); Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac); Assert.Equal("router.lan", hosts[0].Hostname); Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries } [Fact] public async Task Results_come_back_in_address_order_whatever_order_probes_finished() { var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] }; IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip)); } [Fact] public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() { var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) }; NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 }; await NetworkScanner.ScanAsync(probe, options); Assert.True(probe.MaxInFlight <= 4, $"{probe.MaxInFlight} hosts were probed at once; the cap was 4."); } [Fact] public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() { // The floor lives in the scanner, not a front end: an auto-detected VPN /10 // must hit the same wall a typed --cidr does. await Assert.ThrowsAsync(() => NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8"))); } /// Scripted IO: answers what it is told to, records what was asked of it. private sealed class ScriptedProbe : INetworkProbe { private readonly Lock _lock = new(); private int _inFlight; private bool _sweepDone; public List PingReplies { get; init; } = []; public List<(string Ip, int Port)> OpenPorts { get; init; } = []; public string? Arp { get; init; } public Dictionary Names { get; } = []; public TimeSpan PingDelay { get; init; } = TimeSpan.Zero; public List<(string Ip, int Port)> PortProbes { get; } = []; public List DnsLookups { get; } = []; public bool IsSupported => true; public int MaxInFlight { get; private set; } public bool ArpReadAfterSweep { get; private set; } public async Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) { lock (_lock) { _inFlight++; MaxInFlight = Math.Max(MaxInFlight, _inFlight); } try { if (PingDelay > TimeSpan.Zero) await Task.Delay(PingDelay, cancellationToken); return PingReplies.Contains(ip); } finally { lock (_lock) { _inFlight--; } } } public Task TryConnectAsync( string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default) { lock (_lock) { PortProbes.Add((ip, port)); } return Task.FromResult(OpenPorts.Contains((ip, port))); } public Task ReadArpAsync(CancellationToken cancellationToken = default) { lock (_lock) { _sweepDone = true; ArpReadAfterSweep = _inFlight == 0; } return Task.FromResult(Arp); } public Task ReverseDnsAsync( string ip, TimeSpan timeout, CancellationToken cancellationToken = default) { lock (_lock) { if (!_sweepDone) throw new InvalidOperationException("Reverse DNS ran before the sweep finished."); DnsLookups.Add(ip); } return Task.FromResult(Names.GetValueOrDefault(ip)); } public Cidr? LocalSubnet() => null; } }