ProxmoxClientTests.cs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. using System.Net;
  2. using RackPeek.Domain.Discovery;
  3. namespace Tests.Discovery;
  4. /// <summary>
  5. /// Exercises the IO half against a stub that records what was actually asked for.
  6. /// Path construction and the authorization header cannot be checked any other way
  7. /// without a Proxmox to point at.
  8. /// </summary>
  9. public class ProxmoxClientTests {
  10. private static (ProxmoxApiClient Client, StubHandler Stub) Create(
  11. string host = "https://pve.lan:8006",
  12. bool clusterAvailable = true) {
  13. var stub = new StubHandler(clusterAvailable);
  14. return (new ProxmoxApiClient(host, "root@pam!rackpeek", "secret-uuid", false, new HttpClient(stub)), stub);
  15. }
  16. [Fact]
  17. public async Task Requests_go_to_the_documented_api_paths() {
  18. (ProxmoxApiClient client, StubHandler stub) = Create();
  19. using (client) {
  20. await client.GetNodesAsync();
  21. await client.EnrichAsync(new ProxmoxNode { Name = "pve01" });
  22. await client.GetGuestsAsync("pve01", ProxmoxApiClient.QemuEndpoint);
  23. await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.LxcEndpoint, 201);
  24. }
  25. Assert.Equal([
  26. "/api2/json/nodes",
  27. "/api2/json/nodes/pve01/status",
  28. "/api2/json/nodes/pve01/disks/list",
  29. "/api2/json/nodes/pve01/hardware/pci",
  30. "/api2/json/nodes/pve01/qemu",
  31. "/api2/json/nodes/pve01/lxc/201/config"
  32. ], stub.Paths);
  33. }
  34. [Fact]
  35. public async Task The_token_is_sent_the_way_proxmox_expects_it() {
  36. (ProxmoxApiClient client, StubHandler stub) = Create();
  37. using (client)
  38. await client.GetNodesAsync();
  39. Assert.Equal("PVEAPIToken=root@pam!rackpeek=secret-uuid", stub.Authorization);
  40. }
  41. [Theory]
  42. [InlineData("pve.lan", "https://pve.lan:8006")]
  43. [InlineData("https://pve.lan:8006", "https://pve.lan:8006")]
  44. [InlineData("https://pve.lan:8006/", "https://pve.lan:8006")]
  45. [InlineData("http://10.0.50.10:8006", "http://10.0.50.10:8006")]
  46. public void A_bare_host_name_gets_the_scheme_and_port_proxmox_uses(string input, string expected) {
  47. (ProxmoxApiClient client, _) = Create(input);
  48. using (client)
  49. Assert.Equal(expected, client.Endpoint);
  50. }
  51. [Fact]
  52. public async Task A_clustered_host_identifies_guests_by_the_cluster() {
  53. (ProxmoxApiClient client, _) = Create();
  54. using (client)
  55. Assert.Equal("homelab", await client.GetIdentityScopeAsync());
  56. }
  57. [Fact]
  58. public async Task A_standalone_host_has_no_cluster_endpoint_and_falls_back_to_its_node() {
  59. // Proxmox answers an error rather than an empty list when there is no cluster.
  60. (ProxmoxApiClient client, _) = Create(clusterAvailable: false);
  61. using (client)
  62. Assert.Equal("pve01", await client.GetIdentityScopeAsync());
  63. }
  64. [Fact]
  65. public async Task A_guest_that_vanishes_mid_run_contributes_nothing_instead_of_failing() {
  66. (ProxmoxApiClient client, _) = Create();
  67. using (client) {
  68. ProxmoxGuestConfig config =
  69. await client.GetGuestConfigAsync("pve01", ProxmoxApiClient.QemuEndpoint, 999);
  70. Assert.Null(config.Os);
  71. Assert.Null(config.Ip);
  72. }
  73. }
  74. [Fact]
  75. public async Task A_rejected_token_says_so_in_terms_that_point_at_the_fix() {
  76. var stub = new StubHandler(true) { Unauthorized = true };
  77. using var client = new ProxmoxApiClient(
  78. "https://pve.lan:8006", "bad", "worse", false, new HttpClient(stub));
  79. HttpRequestException error =
  80. await Assert.ThrowsAsync<HttpRequestException>(() => client.GetNodesAsync());
  81. Assert.Contains("user@realm!tokenname", error.Message);
  82. }
  83. private sealed class StubHandler(bool clusterAvailable) : HttpMessageHandler {
  84. public List<string> Paths { get; } = [];
  85. public string? Authorization { get; private set; }
  86. public bool Unauthorized { get; init; }
  87. protected override Task<HttpResponseMessage> SendAsync(
  88. HttpRequestMessage request,
  89. CancellationToken cancellationToken) {
  90. var path = request.RequestUri!.AbsolutePath;
  91. Paths.Add(path);
  92. Authorization = request.Headers.TryGetValues("Authorization", out IEnumerable<string>? values)
  93. ? string.Join("", values)
  94. : null;
  95. if (Unauthorized)
  96. return Respond(HttpStatusCode.Unauthorized, "{}");
  97. return path switch {
  98. "/api2/json/nodes" => Respond(HttpStatusCode.OK, Fixture.Read("pve-nodes-full.json")),
  99. "/api2/json/cluster/status" when clusterAvailable =>
  100. Respond(HttpStatusCode.OK, Fixture.Read("pve-cluster-status.json")),
  101. "/api2/json/cluster/status" => Respond(HttpStatusCode.InternalServerError, "{}"),
  102. "/api2/json/nodes/pve01/status" => Respond(HttpStatusCode.OK, Fixture.Read("pve-node-status.json")),
  103. "/api2/json/nodes/pve01/qemu" => Respond(HttpStatusCode.OK, Fixture.Read("pve-qemu.json")),
  104. "/api2/json/nodes/pve01/lxc" => Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc.json")),
  105. "/api2/json/nodes/pve01/disks/list" => Respond(HttpStatusCode.OK, Fixture.Read("pve-disks.json")),
  106. "/api2/json/nodes/pve01/hardware/pci" => Respond(HttpStatusCode.OK, Fixture.Read("pve-hardware-pci.json")),
  107. "/api2/json/nodes/pve01/lxc/201/config" =>
  108. Respond(HttpStatusCode.OK, Fixture.Read("pve-lxc-config.json")),
  109. _ => Respond(HttpStatusCode.NotFound, "{}")
  110. };
  111. }
  112. private static Task<HttpResponseMessage> Respond(HttpStatusCode status, string body) =>
  113. Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) });
  114. }
  115. }