using System.Net; using RackPeek.Domain.Discovery; namespace Tests.Discovery; /// /// Exercises the IO half against a stub that records what was actually asked for. /// Path construction and the authorization header cannot be checked any other way /// without a Proxmox to point at. /// public class ProxmoxClientTests { private static (ProxmoxApiClient Client, StubHandler Stub) Create( string host = "https://pve.lan:8006", bool clusterAvailable = true) { var stub = new StubHandler(clusterAvailable); return (new ProxmoxApiClient(host, "root@pam!rackpeek", "secret-uuid", false, new HttpClient(stub)), stub); } [Fact] public async Task Requests_go_to_the_documented_api_paths() { (ProxmoxApiClient client, StubHandler stub) = Create(); using (client) { await client.GetNodesAsync(); await client.EnrichAsync(new ProxmoxNode { Name = "pve01" }); await client.GetGuestsAsync("pve01", ProxmoxApiClient.QemuEndpoint); await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.LxcEndpoint, 201); } Assert.Equal([ "/api2/json/nodes", "/api2/json/nodes/pve01/status", "/api2/json/nodes/pve01/disks/list", "/api2/json/nodes/pve01/hardware/pci", "/api2/json/nodes/pve01/qemu", "/api2/json/nodes/pve01/lxc/201/config" ], stub.Paths); } [Fact] public async Task The_token_is_sent_the_way_proxmox_expects_it() { (ProxmoxApiClient client, StubHandler stub) = Create(); using (client) await client.GetNodesAsync(); Assert.Equal("PVEAPIToken=root@pam!rackpeek=secret-uuid", stub.Authorization); } [Theory] [InlineData("pve.lan", "https://pve.lan:8006")] [InlineData("https://pve.lan:8006", "https://pve.lan:8006")] [InlineData("https://pve.lan:8006/", "https://pve.lan:8006")] [InlineData("http://10.0.50.10:8006", "http://10.0.50.10:8006")] public void A_bare_host_name_gets_the_scheme_and_port_proxmox_uses(string input, string expected) { (ProxmoxApiClient client, _) = Create(input); using (client) Assert.Equal(expected, client.Endpoint); } [Fact] public async Task A_clustered_host_identifies_guests_by_the_cluster() { (ProxmoxApiClient client, _) = Create(); using (client) Assert.Equal("homelab", await client.GetIdentityScopeAsync()); } [Fact] public async Task A_standalone_host_has_no_cluster_endpoint_and_falls_back_to_its_node() { // Proxmox answers an error rather than an empty list when there is no cluster. (ProxmoxApiClient client, _) = Create(clusterAvailable: false); using (client) Assert.Equal("pve01", await client.GetIdentityScopeAsync()); } [Fact] public async Task A_guest_that_vanishes_mid_run_contributes_nothing_instead_of_failing() { (ProxmoxApiClient client, _) = Create(); using (client) { ProxmoxGuestConfig config = await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.QemuEndpoint, 999); Assert.Null(config.Os); Assert.Null(config.Ip); } } [Fact] public async Task A_rejected_token_says_so_in_terms_that_point_at_the_fix() { var stub = new StubHandler(true) { Unauthorized = true }; using var client = new ProxmoxApiClient( "https://pve.lan:8006", "bad", "worse", false, new HttpClient(stub)); HttpRequestException error = await Assert.ThrowsAsync(() => client.GetNodesAsync()); Assert.Contains("user@realm!tokenname", error.Message); } private sealed class StubHandler(bool clusterAvailable) : HttpMessageHandler { public List Paths { get; } = []; public string? Authorization { get; private set; } public bool Unauthorized { get; init; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; Paths.Add(path); Authorization = request.Headers.TryGetValues("Authorization", out IEnumerable? values) ? string.Join("", values) : null; if (Unauthorized) return Respond(HttpStatusCode.Unauthorized, "{}"); return path switch { "/api2/json/nodes" => Respond(HttpStatusCode.OK, Fixture.Read("pve-nodes-full.json")), "/api2/json/cluster/status" when clusterAvailable => Respond(HttpStatusCode.OK, Fixture.Read("pve-cluster-status.json")), "/api2/json/cluster/status" => Respond(HttpStatusCode.InternalServerError, "{}"), "/api2/json/nodes/pve01/status" => Respond(HttpStatusCode.OK, Fixture.Read("pve-node-status.json")), "/api2/json/nodes/pve01/qemu" => Respond(HttpStatusCode.OK, Fixture.Read("pve-qemu.json")), "/api2/json/nodes/pve01/lxc" => Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc.json")), "/api2/json/nodes/pve01/disks/list" => Respond(HttpStatusCode.OK, Fixture.Read("pve-disks.json")), "/api2/json/nodes/pve01/hardware/pci" => Respond(HttpStatusCode.OK, Fixture.Read("pve-hardware-pci.json")), "/api2/json/nodes/pve01/lxc/201/config" => Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc-config.json")), _ => Respond(HttpStatusCode.NotFound, "{}") }; } private static Task Respond(HttpStatusCode status, string body) => Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); } }