using RackPeek.Domain.Discovery;
using RackPeek.Domain.Resources;
using RackPeek.Domain.Resources.Services;
using RackPeek.Domain.Resources.SystemResources;
namespace Tests.Discovery;
///
/// How a swept host gets a name and a vendor. Sweeping a homelab that has no PTR
/// records used to produce a page of "host-<hash>" cards; these tests own the
/// rules that turn those into recognisable machines.
///
public class NetworkIdentityTests {
private static NetworkHostFact Host(
string ip,
string? mac = null,
string? hostname = null,
ServiceIdentity? identity = null,
string? vendor = null,
IReadOnlyList? services = null) =>
new(ip, mac, hostname, true, []) {
Identity = identity,
Vendor = vendor,
Services = services ?? []
};
private static SystemResource Single(params NetworkHostFact[] hosts) =>
Assert.IsType(Assert.Single(NetworkScanMapper.ToResources(hosts)));
[Fact]
public void A_certificate_name_becomes_the_card_name() {
SystemResource card = Single(Host(
"192.0.2.13",
identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
// The label is the host part, as it is for a PTR name.
Assert.Equal("pve-node-01", card.Name);
}
[Fact]
public void A_ptr_record_still_wins_over_a_service_banner() {
// The network's own answer beats whatever a certificate happens to say — a
// stale or copied certificate must never rename a host DNS already knows.
SystemResource card = Single(Host(
"192.0.2.13",
hostname: "pve-01.lan",
identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
Assert.Equal("pve-01", card.Name);
}
[Fact]
public void Without_any_name_the_card_still_falls_back_to_the_hash() {
SystemResource card = Single(Host("192.0.2.111"));
Assert.StartsWith("host-", card.Name);
}
[Fact]
public void What_the_host_said_is_recorded_even_when_it_named_the_card() {
SystemResource card = Single(Host(
"192.0.2.204",
identity: new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)));
// Whoever reads the card can see the name came from an HTTP title on 8123 and
// judge it accordingly, rather than trusting a name of unknown provenance.
Assert.Equal("http:8123 Home Assistant", card.Labels["identified-by"]);
}
[Fact]
public void An_ssh_greeting_annotates_but_never_names() {
// Every Linux box on a subnet runs the same daemon. Naming from the greeting
// produced five cards called "openssh" on a real sweep — no more use than five
// called "host-", and misleading about what was actually identified.
SystemResource card = Single(Host(
"192.0.2.209",
identity: new ServiceIdentity("OpenSSH", IdentitySource.SshBanner, 22)));
Assert.StartsWith("host-", card.Name);
Assert.Equal("ssh:22 OpenSSH", card.Labels["identified-by"]);
}
[Fact]
public void An_application_that_named_itself_over_http_becomes_a_service_on_its_host() {
List cards = NetworkScanMapper.ToResources([
Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
]);
SystemResource host = Assert.Single(cards.OfType());
Service service = Assert.Single(cards.OfType());
Assert.Equal("home-assistant", service.Name);
Assert.Equal([host.Name], service.RunsOn);
Assert.Equal("192.0.2.204", service.Network?.Ip);
Assert.Equal(8123, service.Network?.Port);
}
[Fact]
public void A_page_title_names_the_service_and_leaves_the_host_a_hash() {
// A host may run several applications, so naming the machine after whichever
// answered first is arbitrary. Only a certificate names the machine itself.
List cards = NetworkScanMapper.ToResources([
Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
]);
Assert.StartsWith("host-", Assert.Single(cards.OfType()).Name);
}
[Fact]
public void Several_applications_on_one_host_each_get_their_own_service() {
List cards = NetworkScanMapper.ToResources([
Host("192.0.2.105", services: [
new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
])
]);
var services = cards.OfType().ToList();
Assert.Equal(2, services.Count);
Assert.Equal([9000, 3000], services.Select(s => s.Network!.Port));
// Each port is its own thing, so each keeps its own stable id.
Assert.Equal(2, services.Select(s => s.DiscoveryId).Distinct().Count());
}
[Fact]
public void An_appliances_own_management_page_is_named_for_the_port_it_serves() {
// A firewall whose page says "OPNsense" on a card already called opnsense would
// otherwise gain a second card named opnsense-, saying nothing new. The
// port is what distinguishes the service from the box it runs on.
List cards = NetworkScanMapper.ToResources([
Host(
"192.0.2.101",
identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443),
services: [new ServiceIdentity("OPNsense", IdentitySource.Http, 80)])
]);
Assert.Equal("opnsense", Assert.Single(cards.OfType()).Name);
Assert.Equal("opnsense-http", Assert.Single(cards.OfType()).Name);
}
[Fact]
public void A_services_id_survives_a_rescan_and_differs_per_port() {
NetworkHostFact host = Host(
"192.0.2.105",
"bc:24:11:00:1a:01",
services: [
new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
]);
var first = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList();
var second = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList();
Assert.Equal(first, second);
Assert.Equal(2, first.Distinct().Count());
}
[Fact]
public void A_vendor_is_labelled_when_the_mac_is_known() {
SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif"));
Assert.Equal("Espressif", card.Labels["nic-vendor"]);
Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]);
}
[Fact]
public void No_vendor_label_is_invented_when_none_is_known() {
SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33"));
Assert.False(card.Labels.ContainsKey("nic-vendor"));
}
[Fact]
public void The_card_stays_sparse_whatever_identification_found() {
// The sparseness contract: a scan sees an address, never an OS or a core count.
// Anything written here would overwrite the real values on the next rescan of a
// card an agent collector has since filled in.
SystemResource card = Single(Host(
"192.0.2.13",
"bc:24:11:00:1a:01",
identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006),
vendor: "Proxmox"));
Assert.Null(card.Type);
Assert.Null(card.Os);
Assert.Null(card.Cores);
Assert.Null(card.Ram);
Assert.Equal("192.0.2.13", card.Ip);
}
[Fact]
public void Identity_does_not_move_a_hosts_discovery_id() {
// Identity is seeded on the MAC; a name learned from a service must not change
// it, or every card would be recreated the day someone fixes a certificate.
NetworkHostFact withoutName = Host("192.0.2.13", "bc:24:11:00:1a:01");
NetworkHostFact withName = Host(
"192.0.2.13",
"bc:24:11:00:1a:01",
identity: new ServiceIdentity("pve-node-01", IdentitySource.TlsCertificate, 8006));
Assert.Equal(
Single(withoutName).DiscoveryId,
Single(withName).DiscoveryId);
}
[Fact]
public void Two_hosts_naming_themselves_the_same_still_get_distinct_names() {
// Every OPNsense VLAN gateway presents the same certificate CN.
List cards = NetworkScanMapper.ToResources([
Host("192.0.2.101", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)),
Host("192.0.2.141", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443))
]);
Assert.Equal(2, cards.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}