| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- using RackPeek.Domain.Discovery;
- using RackPeek.Domain.Resources.Services.Networking;
- namespace Tests.Discovery;
- /// <summary>
- /// 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.
- /// </summary>
- 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<ArgumentOutOfRangeException>(() =>
- NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
- }
- /// <summary>Scripted IO: answers what it is told to, records what was asked of it.</summary>
- private sealed class ScriptedProbe : INetworkProbe {
- private readonly Lock _lock = new();
- private int _inFlight;
- private bool _sweepDone;
- public List<string> PingReplies { get; init; } = [];
- public List<(string Ip, int Port)> OpenPorts { get; init; } = [];
- public string? Arp { get; init; }
- public Dictionary<string, string> Names { get; } = [];
- public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
- public List<(string Ip, int Port)> PortProbes { get; } = [];
- public List<string> DnsLookups { get; } = [];
- public bool IsSupported => true;
- public int MaxInFlight { get; private set; }
- public bool ArpReadAfterSweep { get; private set; }
- public async Task<bool> 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<bool> 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<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
- lock (_lock) {
- _sweepDone = true;
- ArpReadAfterSweep = _inFlight == 0;
- }
- return Task.FromResult(Arp);
- }
- public Task<string?> 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;
- }
- }
|