InventoryEndpointTests.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. using System.Net;
  2. using System.Net.Http.Json;
  3. using RackPeek.Domain.Api;
  4. using Xunit.Abstractions;
  5. namespace Tests.Api;
  6. public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(output)
  7. {
  8. [Fact]
  9. public async Task DryRun_Add_New_Resource_Does_Not_Persist()
  10. {
  11. var client = CreateClient(withApiKey: true);
  12. var yaml = """
  13. resources:
  14. - name: example-server
  15. kind: Server
  16. """;
  17. var response = await client.PostAsJsonAsync("/api/inventory",
  18. new
  19. {
  20. yaml,
  21. dryRun = true
  22. });
  23. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  24. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  25. Assert.Single(result!.Added);
  26. Assert.Contains("example-server", result.Added);
  27. // Call again — still should be "added" because dry run did not persist
  28. var response2 = await client.PostAsJsonAsync("/api/inventory",
  29. new
  30. {
  31. yaml,
  32. dryRun = true
  33. });
  34. var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  35. Assert.Single(result2!.Added);
  36. }
  37. [Fact]
  38. public async Task Merge_Add_New_Resource_Persists()
  39. {
  40. var client = CreateClient(withApiKey: true);
  41. var yaml = """
  42. version: 2
  43. resources:
  44. - kind: Server
  45. name: server-merge
  46. """;
  47. var response = await client.PostAsJsonAsync("/api/inventory",
  48. new
  49. {
  50. Yaml = yaml,
  51. mode = "Merge"
  52. });
  53. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  54. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  55. Assert.Single(result!.Added);
  56. // Now second call should detect no change
  57. var response2 = await client.PostAsJsonAsync("/api/inventory",
  58. new
  59. {
  60. yaml,
  61. dryRun = true
  62. });
  63. var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  64. Assert.Empty(result2!.Added);
  65. Assert.Empty(result2.Updated);
  66. Assert.Empty(result2.Replaced);
  67. }
  68. [Fact]
  69. public async Task Merge_Updates_Existing_Resource()
  70. {
  71. var client = CreateClient(withApiKey: true);
  72. var initial = """
  73. version: 2
  74. resources:
  75. - kind: Server
  76. name: server-update
  77. ipmi: true
  78. """;
  79. await client.PostAsJsonAsync("/api/inventory",
  80. new { Yaml = initial });
  81. var update = """
  82. version: 2
  83. resources:
  84. - kind: Server
  85. name: server-update
  86. ipmi: false
  87. """;
  88. var response = await client.PostAsJsonAsync("/api/inventory",
  89. new
  90. {
  91. Yaml = update,
  92. mode = "Merge"
  93. });
  94. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  95. Assert.Single(result!.Updated);
  96. Assert.Contains("server-update", result.Updated);
  97. }
  98. [Fact]
  99. public async Task Replace_Replaces_Existing_Resource()
  100. {
  101. var client = CreateClient(withApiKey: true);
  102. var initial = """
  103. resources:
  104. - kind: Server
  105. name: server-replace
  106. ipmi: true
  107. """;
  108. await client.PostAsJsonAsync("/api/inventory",
  109. new { yaml = initial });
  110. var replace = """
  111. resources:
  112. - kind: Server
  113. name: server-replace
  114. """;
  115. var response = await client.PostAsJsonAsync("/api/inventory",
  116. new
  117. {
  118. yaml = replace,
  119. mode = "Replace"
  120. });
  121. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  122. Assert.Single(result!.Replaced);
  123. Assert.Contains("server-replace", result.Replaced);
  124. }
  125. [Fact]
  126. public async Task Invalid_Yaml_Returns_400()
  127. {
  128. var client = CreateClient(withApiKey: true);
  129. var response = await client.PostAsJsonAsync("/api/inventory",
  130. new
  131. {
  132. yaml = "not: valid: yaml:",
  133. });
  134. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  135. }
  136. [Fact]
  137. public async Task Missing_Resources_Section_Returns_400()
  138. {
  139. var client = CreateClient(withApiKey: true);
  140. var yaml = """
  141. somethingElse:
  142. - name: test
  143. """;
  144. var response = await client.PostAsJsonAsync("/api/inventory",
  145. new { yaml });
  146. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  147. }
  148. [Fact]
  149. public async Task Accepts_Json_Root_Input()
  150. {
  151. var client = CreateClient(withApiKey: true);
  152. var response = await client.PostAsJsonAsync("/api/inventory",
  153. new
  154. {
  155. json = new
  156. {
  157. version = 1,
  158. resources = new[]
  159. {
  160. new { kind = "Server", name = "json-server", }
  161. }
  162. }
  163. });
  164. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  165. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  166. Assert.Single(result!.Added);
  167. Assert.Contains("json-server", result.Added);
  168. }
  169. [Fact]
  170. public async Task Requires_Api_Key()
  171. {
  172. var client = CreateClient();
  173. var yaml = """
  174. resources:
  175. - name: no-auth
  176. kind: Server
  177. """;
  178. var response = await client.PostAsJsonAsync("/api/inventory",
  179. new { yaml });
  180. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  181. }
  182. [Fact]
  183. public async Task Import_Full_Config_Works()
  184. {
  185. var client = CreateClient(withApiKey: true);
  186. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  187. // Put your big sample YAML in TestData folder
  188. var response = await client.PostAsJsonAsync("/api/inventory",
  189. new { yaml });
  190. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  191. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  192. Assert.True(result!.Added.Count > 10);
  193. Assert.Empty(result.Updated);
  194. Assert.Empty(result.Replaced);
  195. }
  196. [Fact]
  197. public async Task Import_Full_Config_Twice_Is_Idempotent()
  198. {
  199. var client = CreateClient(withApiKey: true);
  200. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  201. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  202. var response2 = await client.PostAsJsonAsync("/api/inventory",
  203. new { yaml, dryRun = true });
  204. var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  205. Assert.Empty(result2!.Added);
  206. Assert.Empty(result2.Updated);
  207. Assert.Empty(result2.Replaced);
  208. }
  209. [Fact]
  210. public async Task Merge_Updates_Nested_Object()
  211. {
  212. var client = CreateClient(withApiKey: true);
  213. var initial = """
  214. version: 2
  215. resources:
  216. - kind: Server
  217. name: nested-test
  218. ram:
  219. size: 64
  220. mts: 2666
  221. """;
  222. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  223. var update = """
  224. version: 2
  225. resources:
  226. - kind: Server
  227. name: nested-test
  228. ram:
  229. size: 128
  230. """;
  231. var response = await client.PostAsJsonAsync("/api/inventory",
  232. new { yaml = update, mode = "Merge" });
  233. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  234. Assert.Single(result!.Updated);
  235. }
  236. [Fact]
  237. public async Task Merge_Does_Not_Clear_List_When_Empty()
  238. {
  239. var client = CreateClient(withApiKey: true);
  240. var initial = """
  241. resources:
  242. - kind: Server
  243. name: drive-test
  244. drives:
  245. - type: ssd
  246. size: 1024
  247. """;
  248. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  249. var update = """
  250. resources:
  251. - kind: Server
  252. name: drive-test
  253. drives: []
  254. """;
  255. var response = await client.PostAsJsonAsync("/api/inventory",
  256. new { yaml = update, mode = "Merge" });
  257. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  258. // Should NOT count as update because empty list ignored
  259. Assert.Empty(result!.Updated);
  260. }
  261. [Fact]
  262. public async Task Replace_Clears_List()
  263. {
  264. var client = CreateClient(withApiKey: true);
  265. var initial = """
  266. resources:
  267. - kind: Server
  268. name: replace-drive-test
  269. drives:
  270. - type: ssd
  271. size: 1024
  272. """;
  273. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  274. var replace = """
  275. resources:
  276. - kind: Server
  277. name: replace-drive-test
  278. drives: []
  279. """;
  280. var response = await client.PostAsJsonAsync("/api/inventory",
  281. new { yaml = replace, mode = "Replace" });
  282. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  283. Assert.Single(result!.Replaced);
  284. }
  285. [Fact]
  286. public async Task Type_Change_Forces_Replace()
  287. {
  288. var client = CreateClient(withApiKey: true);
  289. var initial = """
  290. version: 2
  291. resources:
  292. - kind: Server
  293. name: polymorph-test
  294. """;
  295. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  296. var update = """
  297. version: 2
  298. resources:
  299. - kind: Firewall
  300. name: polymorph-test
  301. """;
  302. var response = await client.PostAsJsonAsync("/api/inventory",
  303. new { yaml = update, mode = "Merge" });
  304. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  305. Assert.Single(result!.Replaced);
  306. }
  307. [Fact]
  308. public async Task Name_Matching_Is_Case_Insensitive()
  309. {
  310. var client = CreateClient(withApiKey: true);
  311. var initial = """
  312. resources:
  313. - kind: Server
  314. name: CaseTest
  315. """;
  316. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  317. var update = """
  318. resources:
  319. - kind: Server
  320. name: casetest
  321. ipmi: true
  322. """;
  323. var response = await client.PostAsJsonAsync("/api/inventory",
  324. new { yaml = update });
  325. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  326. Assert.Single(result!.Updated);
  327. }
  328. [Fact]
  329. public async Task Multiple_Resources_Are_Processed()
  330. {
  331. var client = CreateClient(withApiKey: true);
  332. var yaml = """
  333. resources:
  334. - kind: Server
  335. name: multi-1
  336. - kind: Firewall
  337. name: multi-2
  338. """;
  339. var response = await client.PostAsJsonAsync("/api/inventory",
  340. new { yaml });
  341. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  342. Assert.Equal(2, result!.Added.Count);
  343. }
  344. [Fact]
  345. public async Task DryRun_Replace_Does_Not_Persist()
  346. {
  347. var client = CreateClient(withApiKey: true);
  348. var initial = """
  349. resources:
  350. - kind: Server
  351. name: dry-replace
  352. ipmi: true
  353. """;
  354. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  355. var replace = """
  356. resources:
  357. - kind: Server
  358. name: dry-replace
  359. """;
  360. await client.PostAsJsonAsync("/api/inventory",
  361. new { yaml = replace, mode = "Replace", dryRun = true });
  362. var check = await client.PostAsJsonAsync("/api/inventory",
  363. new { yaml = replace, mode = "Replace", dryRun = true });
  364. var result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
  365. Assert.Single(result!.Replaced);
  366. }
  367. [Fact]
  368. public async Task Providing_Both_Yaml_And_Json_Returns_400()
  369. {
  370. var client = CreateClient(withApiKey: true);
  371. var response = await client.PostAsJsonAsync("/api/inventory",
  372. new
  373. {
  374. yaml = "resources: []",
  375. json = new { resources = Array.Empty<object>() }
  376. });
  377. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  378. }
  379. [Fact]
  380. public async Task Empty_Request_Returns_400()
  381. {
  382. var client = CreateClient(withApiKey: true);
  383. var response = await client.PostAsJsonAsync("/api/inventory", new { });
  384. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  385. }
  386. [Fact]
  387. public async Task Version_1_Config_Is_Accepted()
  388. {
  389. var client = CreateClient(withApiKey: true);
  390. var yaml = """
  391. version: 1
  392. resources:
  393. - kind: Server
  394. name: v1-server
  395. """;
  396. var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  397. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  398. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  399. Assert.Single(result!.Added);
  400. }
  401. [Fact]
  402. public async Task Replace_Removes_Existing_Fields()
  403. {
  404. var client = CreateClient(withApiKey: true);
  405. var initial = """
  406. resources:
  407. - kind: Server
  408. name: destructive-test
  409. ipmi: true
  410. """;
  411. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  412. var replace = """
  413. resources:
  414. - kind: Server
  415. name: destructive-test
  416. """;
  417. var response = await client.PostAsJsonAsync("/api/inventory",
  418. new { yaml = replace, mode = "Replace" });
  419. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  420. Assert.Single(result!.Replaced);
  421. Assert.Contains("destructive-test", result.OldYaml.Keys);
  422. Assert.Contains("destructive-test", result.NewYaml.Keys);
  423. }
  424. [Fact]
  425. public async Task Merge_Does_Not_Affect_Unspecified_Resources()
  426. {
  427. var client = CreateClient(withApiKey: true);
  428. var full = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  429. await client.PostAsJsonAsync("/api/inventory", new { yaml = full });
  430. var update = """
  431. resources:
  432. - kind: Server
  433. name: proxmox-node01
  434. ipmi: false
  435. """;
  436. var response = await client.PostAsJsonAsync("/api/inventory",
  437. new { yaml = update, mode = "Merge" });
  438. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  439. Assert.Single(result!.Updated);
  440. Assert.DoesNotContain("proxmox-node02", result.Updated);
  441. }
  442. [Fact]
  443. public async Task Json_Input_Resolves_Polymorphic_Resource()
  444. {
  445. var client = CreateClient(withApiKey: true);
  446. var response = await client.PostAsJsonAsync("/api/inventory",
  447. new
  448. {
  449. json = new
  450. {
  451. version = 2,
  452. resources = new[]
  453. {
  454. new { kind = "Firewall", name = "json-fw", model = "Test" }
  455. }
  456. }
  457. });
  458. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  459. Assert.Single(result!.Added);
  460. Assert.Contains("json-fw", result.Added);
  461. }
  462. [Fact]
  463. public async Task Large_Config_Is_Fully_Idempotent()
  464. {
  465. var client = CreateClient(withApiKey: true);
  466. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  467. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  468. var response = await client.PostAsJsonAsync("/api/inventory",
  469. new { yaml });
  470. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  471. Assert.Empty(result!.Added);
  472. Assert.Empty(result.Updated);
  473. Assert.Empty(result.Replaced);
  474. }
  475. [Fact]
  476. public async Task Unknown_Kind_Returns_400()
  477. {
  478. var client = CreateClient(withApiKey: true);
  479. var yaml = """
  480. resources:
  481. - kind: UnknownThing
  482. name: mystery
  483. """;
  484. var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  485. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  486. }
  487. [Fact]
  488. public async Task DryRun_Does_Not_Persist_Snapshots()
  489. {
  490. var client = CreateClient(withApiKey: true);
  491. var yaml = """
  492. resources:
  493. - kind: Server
  494. name: dry-snapshot
  495. """;
  496. var response = await client.PostAsJsonAsync("/api/inventory",
  497. new { yaml, dryRun = true });
  498. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  499. Assert.Empty(result!.OldYaml);
  500. }
  501. [Fact]
  502. public async Task Reordering_List_Does_Not_Count_As_Update()
  503. {
  504. var client = CreateClient(withApiKey: true);
  505. var initial = """
  506. resources:
  507. - kind: Server
  508. name: order-test
  509. drives:
  510. - type: ssd
  511. size: 1024
  512. - type: hdd
  513. size: 4096
  514. """;
  515. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  516. var reordered = """
  517. resources:
  518. - kind: Server
  519. name: order-test
  520. drives:
  521. - type: hdd
  522. size: 4096
  523. - type: ssd
  524. size: 1024
  525. """;
  526. var response = await client.PostAsJsonAsync("/api/inventory",
  527. new { yaml = reordered });
  528. var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  529. Assert.Single(result!.Updated);
  530. Assert.Contains("order-test", result.Updated);
  531. }
  532. [Fact]
  533. public async Task Duplicate_Names_In_Same_Request_Returns_400()
  534. {
  535. var client = CreateClient(withApiKey: true);
  536. var yaml = """
  537. resources:
  538. - kind: Server
  539. name: dup
  540. - kind: Server
  541. name: dup
  542. """;
  543. var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  544. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  545. }
  546. }