InventoryEndpointTests.cs 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  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. [Fact]
  8. public async Task DryRun_Add_New_Resource_Does_Not_Persist() {
  9. HttpClient client = CreateClient(true);
  10. var yaml = """
  11. resources:
  12. - name: example-server
  13. kind: Server
  14. """;
  15. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  16. new {
  17. yaml,
  18. dryRun = true
  19. });
  20. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  21. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  22. Assert.Single(result!.Added);
  23. Assert.Contains("example-server", result.Added);
  24. // Call again — still should be "added" because dry run did not persist
  25. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  26. new {
  27. yaml,
  28. dryRun = true
  29. });
  30. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  31. Assert.Single(result2!.Added);
  32. }
  33. [Fact]
  34. public async Task Merge_Add_New_Resource_Persists() {
  35. HttpClient client = CreateClient(true);
  36. var yaml = """
  37. version: 2
  38. resources:
  39. - kind: Server
  40. name: server-merge
  41. """;
  42. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  43. new {
  44. Yaml = yaml,
  45. mode = "Merge"
  46. });
  47. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  48. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  49. Assert.Single(result!.Added);
  50. // Now second call should detect no change
  51. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  52. new {
  53. yaml,
  54. dryRun = true
  55. });
  56. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  57. Assert.Empty(result2!.Added);
  58. Assert.Empty(result2.Updated);
  59. Assert.Empty(result2.Replaced);
  60. }
  61. [Fact]
  62. public async Task Merge_Updates_Existing_Resource() {
  63. HttpClient client = CreateClient(true);
  64. var initial = """
  65. version: 2
  66. resources:
  67. - kind: Server
  68. name: server-update
  69. ipmi: true
  70. """;
  71. await client.PostAsJsonAsync("/api/inventory",
  72. new { Yaml = initial });
  73. var update = """
  74. version: 3
  75. resources:
  76. - kind: Server
  77. name: server-update
  78. ipmi: false
  79. """;
  80. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  81. new {
  82. Yaml = update,
  83. mode = "Merge"
  84. });
  85. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  86. Assert.Single(result!.Updated);
  87. Assert.Contains("server-update", result.Updated);
  88. }
  89. [Fact]
  90. public async Task Replace_Replaces_Existing_Resource() {
  91. HttpClient client = CreateClient(true);
  92. var initial = """
  93. resources:
  94. - kind: Server
  95. name: server-replace
  96. ipmi: true
  97. """;
  98. await client.PostAsJsonAsync("/api/inventory",
  99. new { yaml = initial });
  100. var replace = """
  101. resources:
  102. - kind: Server
  103. name: server-replace
  104. """;
  105. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  106. new {
  107. yaml = replace,
  108. mode = "Replace"
  109. });
  110. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  111. Assert.Single(result!.Replaced);
  112. Assert.Contains("server-replace", result.Replaced);
  113. }
  114. [Fact]
  115. public async Task Invalid_Yaml_Returns_400() {
  116. HttpClient client = CreateClient(true);
  117. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  118. new {
  119. yaml = "not: valid: yaml:"
  120. });
  121. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  122. }
  123. [Fact]
  124. public async Task Missing_Resources_Section_Returns_400() {
  125. HttpClient client = CreateClient(true);
  126. var yaml = """
  127. somethingElse:
  128. - name: test
  129. """;
  130. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  131. new { yaml });
  132. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  133. }
  134. [Fact]
  135. public async Task Accepts_Json_Root_Input() {
  136. HttpClient client = CreateClient(true);
  137. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  138. new {
  139. json = new {
  140. version = 1,
  141. resources = new[]
  142. {
  143. new { kind = "Server", name = "json-server" }
  144. }
  145. }
  146. });
  147. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  148. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  149. Assert.Single(result!.Added);
  150. Assert.Contains("json-server", result.Added);
  151. }
  152. [Fact]
  153. public async Task Requires_Api_Key() {
  154. HttpClient client = CreateClient();
  155. var yaml = """
  156. resources:
  157. - name: no-auth
  158. kind: Server
  159. """;
  160. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  161. new { yaml });
  162. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  163. }
  164. [Fact]
  165. public async Task Import_Full_Config_Works() {
  166. HttpClient client = CreateClient(true);
  167. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  168. // Put your big sample YAML in TestData folder
  169. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  170. new { yaml });
  171. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  172. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  173. Assert.True(result!.Added.Count > 10);
  174. Assert.Empty(result.Updated);
  175. Assert.Empty(result.Replaced);
  176. }
  177. [Fact]
  178. public async Task Import_Full_Config_Twice_Is_Idempotent() {
  179. HttpClient client = CreateClient(true);
  180. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  181. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  182. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  183. new { yaml, dryRun = true });
  184. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  185. Assert.Empty(result2!.Added);
  186. Assert.Empty(result2.Updated);
  187. Assert.Empty(result2.Replaced);
  188. }
  189. [Fact]
  190. public async Task Merge_Updates_Nested_Object() {
  191. HttpClient client = CreateClient(true);
  192. var initial = """
  193. version: 2
  194. resources:
  195. - kind: Server
  196. name: nested-test
  197. ram:
  198. size: 64
  199. mts: 2666
  200. """;
  201. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  202. var update = """
  203. version: 3
  204. resources:
  205. - kind: Server
  206. name: nested-test
  207. ram:
  208. size: 128
  209. """;
  210. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  211. new { yaml = update, mode = "Merge" });
  212. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  213. Assert.Single(result!.Updated);
  214. }
  215. [Fact]
  216. public async Task Merge_Does_Not_Clear_List_When_Empty() {
  217. HttpClient client = CreateClient(true);
  218. var initial = """
  219. resources:
  220. - kind: Server
  221. name: drive-test
  222. drives:
  223. - type: ssd
  224. size: 1024
  225. """;
  226. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  227. var update = """
  228. resources:
  229. - kind: Server
  230. name: drive-test
  231. drives: []
  232. """;
  233. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  234. new { yaml = update, mode = "Merge" });
  235. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  236. // Should NOT count as update because empty list ignored
  237. Assert.Empty(result!.Updated);
  238. }
  239. [Fact]
  240. public async Task Replace_Clears_List() {
  241. HttpClient client = CreateClient(true);
  242. var initial = """
  243. resources:
  244. - kind: Server
  245. name: replace-drive-test
  246. drives:
  247. - type: ssd
  248. size: 1024
  249. """;
  250. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  251. var replace = """
  252. resources:
  253. - kind: Server
  254. name: replace-drive-test
  255. drives: []
  256. """;
  257. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  258. new { yaml = replace, mode = "Replace" });
  259. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  260. Assert.Single(result!.Replaced);
  261. }
  262. [Fact]
  263. public async Task Type_Change_Forces_Replace() {
  264. HttpClient client = CreateClient(true);
  265. var initial = """
  266. version: 2
  267. resources:
  268. - kind: Server
  269. name: polymorph-test
  270. """;
  271. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  272. var update = """
  273. version: 3
  274. resources:
  275. - kind: Firewall
  276. name: polymorph-test
  277. """;
  278. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  279. new { yaml = update, mode = "Merge" });
  280. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  281. Assert.Single(result!.Replaced);
  282. }
  283. [Fact]
  284. public async Task Name_Matching_Is_Case_Insensitive() {
  285. HttpClient client = CreateClient(true);
  286. var initial = """
  287. resources:
  288. - kind: Server
  289. name: CaseTest
  290. """;
  291. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  292. var update = """
  293. resources:
  294. - kind: Server
  295. name: casetest
  296. ipmi: true
  297. """;
  298. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  299. new { yaml = update });
  300. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  301. Assert.Single(result!.Updated);
  302. }
  303. [Fact]
  304. public async Task Multiple_Resources_Are_Processed() {
  305. HttpClient client = CreateClient(true);
  306. var yaml = """
  307. resources:
  308. - kind: Server
  309. name: multi-1
  310. - kind: Firewall
  311. name: multi-2
  312. """;
  313. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  314. new { yaml });
  315. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  316. Assert.Equal(2, result!.Added.Count);
  317. }
  318. [Fact]
  319. public async Task DryRun_Replace_Does_Not_Persist() {
  320. HttpClient client = CreateClient(true);
  321. var initial = """
  322. resources:
  323. - kind: Server
  324. name: dry-replace
  325. ipmi: true
  326. """;
  327. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  328. var replace = """
  329. resources:
  330. - kind: Server
  331. name: dry-replace
  332. """;
  333. await client.PostAsJsonAsync("/api/inventory",
  334. new { yaml = replace, mode = "Replace", dryRun = true });
  335. HttpResponseMessage check = await client.PostAsJsonAsync("/api/inventory",
  336. new { yaml = replace, mode = "Replace", dryRun = true });
  337. ImportYamlResponse? result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
  338. Assert.Single(result!.Replaced);
  339. }
  340. [Fact]
  341. public async Task Providing_Both_Yaml_And_Json_Returns_400() {
  342. HttpClient client = CreateClient(true);
  343. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  344. new {
  345. yaml = "resources: []",
  346. json = new { resources = Array.Empty<object>() }
  347. });
  348. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  349. }
  350. [Fact]
  351. public async Task Empty_Request_Returns_400() {
  352. HttpClient client = CreateClient(true);
  353. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { });
  354. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  355. }
  356. [Fact]
  357. public async Task Version_1_Config_Is_Accepted() {
  358. HttpClient client = CreateClient(true);
  359. var yaml = """
  360. version: 1
  361. resources:
  362. - kind: Server
  363. name: v1-server
  364. """;
  365. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  366. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  367. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  368. Assert.Single(result!.Added);
  369. }
  370. [Fact]
  371. public async Task Replace_Removes_Existing_Fields() {
  372. HttpClient client = CreateClient(true);
  373. var initial = """
  374. resources:
  375. - kind: Server
  376. name: destructive-test
  377. ipmi: true
  378. """;
  379. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  380. var replace = """
  381. resources:
  382. - kind: Server
  383. name: destructive-test
  384. """;
  385. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  386. new { yaml = replace, mode = "Replace" });
  387. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  388. Assert.Single(result!.Replaced);
  389. Assert.Contains("destructive-test", result.OldYaml.Keys);
  390. Assert.Contains("destructive-test", result.NewYaml.Keys);
  391. }
  392. [Fact]
  393. public async Task Merge_Does_Not_Affect_Unspecified_Resources() {
  394. HttpClient client = CreateClient(true);
  395. var full = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  396. await client.PostAsJsonAsync("/api/inventory", new { yaml = full });
  397. var update = """
  398. resources:
  399. - kind: Server
  400. name: proxmox-node01
  401. ipmi: false
  402. """;
  403. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  404. new { yaml = update, mode = "Merge" });
  405. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  406. Assert.Single(result!.Updated);
  407. Assert.DoesNotContain("proxmox-node02", result.Updated);
  408. }
  409. [Fact]
  410. public async Task Json_Input_Resolves_Polymorphic_Resource() {
  411. HttpClient client = CreateClient(true);
  412. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  413. new {
  414. json = new {
  415. version = 2,
  416. resources = new[]
  417. {
  418. new { kind = "Firewall", name = "json-fw", model = "Test" }
  419. }
  420. }
  421. });
  422. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  423. Assert.Single(result!.Added);
  424. Assert.Contains("json-fw", result.Added);
  425. }
  426. [Fact]
  427. public async Task Large_Config_Is_Fully_Idempotent() {
  428. HttpClient client = CreateClient(true);
  429. var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
  430. await client.PostAsJsonAsync("/api/inventory", new { yaml });
  431. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  432. new { yaml });
  433. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  434. Assert.Empty(result!.Added);
  435. Assert.Empty(result.Updated);
  436. Assert.Empty(result.Replaced);
  437. }
  438. [Fact]
  439. public async Task Unknown_Kind_Returns_400() {
  440. HttpClient client = CreateClient(true);
  441. var yaml = """
  442. resources:
  443. - kind: UnknownThing
  444. name: mystery
  445. """;
  446. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  447. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  448. }
  449. [Fact]
  450. public async Task DryRun_Does_Not_Persist_Snapshots() {
  451. HttpClient client = CreateClient(true);
  452. var yaml = """
  453. resources:
  454. - kind: Server
  455. name: dry-snapshot
  456. """;
  457. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  458. new { yaml, dryRun = true });
  459. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  460. Assert.Empty(result!.OldYaml);
  461. }
  462. [Fact]
  463. public async Task Reordering_List_Does_Not_Count_As_Update() {
  464. HttpClient client = CreateClient(true);
  465. var initial = """
  466. resources:
  467. - kind: Server
  468. name: order-test
  469. drives:
  470. - type: ssd
  471. size: 1024
  472. - type: hdd
  473. size: 4096
  474. """;
  475. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  476. var reordered = """
  477. resources:
  478. - kind: Server
  479. name: order-test
  480. drives:
  481. - type: hdd
  482. size: 4096
  483. - type: ssd
  484. size: 1024
  485. """;
  486. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  487. new { yaml = reordered });
  488. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  489. Assert.Single(result!.Updated);
  490. Assert.Contains("order-test", result.Updated);
  491. }
  492. [Fact]
  493. public async Task Duplicate_Names_In_Same_Request_Returns_400() {
  494. HttpClient client = CreateClient(true);
  495. var yaml = """
  496. resources:
  497. - kind: Server
  498. name: dup
  499. - kind: Server
  500. name: dup
  501. """;
  502. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  503. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  504. }
  505. [Fact]
  506. public async Task Health_Endpoint_Is_Anonymous_And_Returns_rackpeek() {
  507. HttpClient client = CreateClient();
  508. HttpResponseMessage response = await client.GetAsync("/health");
  509. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  510. Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
  511. Assert.Equal("rackpeek", await response.Content.ReadAsStringAsync());
  512. }
  513. [Fact]
  514. public async Task Wrong_Api_Key_Returns_401() {
  515. HttpClient client = Factory.CreateClient();
  516. client.DefaultRequestHeaders.Add("X-Api-Key", "definitely-wrong");
  517. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  518. new { yaml = "resources:\n - kind: Server\n name: x" });
  519. Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
  520. }
  521. [Fact]
  522. public async Task Bad_Request_Body_Contains_Error_Field() {
  523. HttpClient client = CreateClient(true);
  524. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { });
  525. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  526. Dictionary<string, string>? body =
  527. await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
  528. Assert.NotNull(body);
  529. Assert.True(body!.ContainsKey("error"));
  530. Assert.False(string.IsNullOrWhiteSpace(body["error"]));
  531. }
  532. [Fact]
  533. public async Task Added_Resource_Has_No_OldYaml_Entry() {
  534. HttpClient client = CreateClient(true);
  535. var yaml = """
  536. resources:
  537. - kind: Server
  538. name: fresh-server
  539. """;
  540. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
  541. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  542. Assert.Contains("fresh-server", result!.Added);
  543. Assert.DoesNotContain("fresh-server", result.OldYaml.Keys);
  544. Assert.Contains("fresh-server", result.NewYaml.Keys);
  545. }
  546. [Fact]
  547. public async Task Merge_Replaces_Tags_Wholesale() {
  548. HttpClient client = CreateClient(true);
  549. var initial = """
  550. resources:
  551. - kind: Server
  552. name: tag-merge
  553. tags:
  554. - alpha
  555. - beta
  556. """;
  557. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  558. var update = """
  559. resources:
  560. - kind: Server
  561. name: tag-merge
  562. tags:
  563. - gamma
  564. """;
  565. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  566. new { yaml = update, mode = "Merge" });
  567. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  568. Assert.Contains("tag-merge", result!.Updated);
  569. var newYaml = result.NewYaml["tag-merge"];
  570. Assert.Contains("gamma", newYaml);
  571. Assert.DoesNotContain("alpha", newYaml);
  572. Assert.DoesNotContain("beta", newYaml);
  573. }
  574. [Theory]
  575. [InlineData("Server")]
  576. [InlineData("Switch")]
  577. [InlineData("Router")]
  578. [InlineData("Firewall")]
  579. [InlineData("AccessPoint")]
  580. [InlineData("Ups")]
  581. [InlineData("Desktop")]
  582. [InlineData("Laptop")]
  583. [InlineData("Service")]
  584. [InlineData("System")]
  585. public async Task Documented_Kind_Discriminator_Values_Are_Accepted(string kind) {
  586. // The inventory-api doc lists the exact set of valid kind discriminator
  587. // values. Every one must be accepted as-is so the doc and runtime stay
  588. // in lockstep.
  589. HttpClient client = CreateClient(true);
  590. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  591. new {
  592. json = new {
  593. version = 3,
  594. resources = new[]
  595. {
  596. new { kind, name = $"kind-probe-{kind.ToLowerInvariant()}" }
  597. }
  598. }
  599. });
  600. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  601. }
  602. [Fact]
  603. public async Task Lowercase_Kind_Is_Rejected() {
  604. // The discriminator is case-sensitive — the doc must use exact casing
  605. // or users hit 400. This test pins that contract so a future refactor
  606. // making the kind case-insensitive would surface here and prompt a
  607. // doc update.
  608. HttpClient client = CreateClient(true);
  609. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  610. new {
  611. json = new {
  612. version = 3,
  613. resources = new[]
  614. {
  615. new { kind = "server", name = "lowercase-probe" }
  616. }
  617. }
  618. });
  619. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  620. }
  621. [Fact]
  622. public async Task Merge_Preserves_Labels_Not_In_Incoming() {
  623. HttpClient client = CreateClient(true);
  624. var initial = """
  625. resources:
  626. - kind: Server
  627. name: label-merge
  628. labels:
  629. env: production
  630. team: backend
  631. """;
  632. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
  633. var update = """
  634. resources:
  635. - kind: Server
  636. name: label-merge
  637. labels:
  638. env: staging
  639. """;
  640. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  641. new { yaml = update, mode = "Merge" });
  642. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  643. Assert.Contains("label-merge", result!.Updated);
  644. var newYaml = result.NewYaml["label-merge"];
  645. Assert.Contains("env: staging", newYaml);
  646. Assert.Contains("team: backend", newYaml);
  647. Assert.DoesNotContain("env: production", newYaml);
  648. }
  649. [Fact]
  650. public async Task Import_Persists_And_Updates_Connections() {
  651. HttpClient client = CreateClient(true);
  652. var initial = """
  653. version: 3
  654. resources:
  655. - kind: Switch
  656. ports:
  657. - type: rj45
  658. speed: 1
  659. count: 8
  660. name: switch1
  661. - kind: Server
  662. ports:
  663. - type: rj45
  664. speed: 1
  665. count: 1
  666. name: server1
  667. connections:
  668. - a:
  669. resource: server1
  670. portGroup: 0
  671. portIndex: 0
  672. b:
  673. resource: switch1
  674. portGroup: 0
  675. portIndex: 0
  676. """;
  677. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  678. new { yaml = initial, mode = "Merge" });
  679. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  680. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  681. Assert.Single(result!.ConnectionsAdded);
  682. Assert.Empty(result.ConnectionsRemoved);
  683. // Re-importing the same config is idempotent
  684. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  685. new { yaml = initial, dryRun = true, mode = "Merge" });
  686. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  687. Assert.Empty(result2!.ConnectionsAdded);
  688. Assert.Empty(result2.ConnectionsRemoved);
  689. // Adjusting the switch port replaces the old connection (#308)
  690. var adjusted = initial.Replace(
  691. """
  692. resource: switch1
  693. portGroup: 0
  694. portIndex: 0
  695. """,
  696. """
  697. resource: switch1
  698. portGroup: 0
  699. portIndex: 3
  700. """);
  701. HttpResponseMessage response3 = await client.PostAsJsonAsync("/api/inventory",
  702. new { yaml = adjusted, mode = "Merge" });
  703. ImportYamlResponse? result3 = await response3.Content.ReadFromJsonAsync<ImportYamlResponse>();
  704. Assert.Single(result3!.ConnectionsAdded);
  705. Assert.Contains("switch1[0.3]", result3.ConnectionsAdded[0]);
  706. Assert.Single(result3.ConnectionsRemoved);
  707. Assert.Contains("switch1[0.0]", result3.ConnectionsRemoved[0]);
  708. // And the adjusted state is now stable
  709. HttpResponseMessage response4 = await client.PostAsJsonAsync("/api/inventory",
  710. new { yaml = adjusted, dryRun = true, mode = "Merge" });
  711. ImportYamlResponse? result4 = await response4.Content.ReadFromJsonAsync<ImportYamlResponse>();
  712. Assert.Empty(result4!.ConnectionsAdded);
  713. Assert.Empty(result4.ConnectionsRemoved);
  714. }
  715. [Fact]
  716. public async Task Import_Without_Connections_Section_Keeps_Existing_Connections() {
  717. HttpClient client = CreateClient(true);
  718. var initial = """
  719. version: 3
  720. resources:
  721. - kind: Switch
  722. ports:
  723. - type: rj45
  724. speed: 1
  725. count: 8
  726. name: sw-keep
  727. - kind: Server
  728. ports:
  729. - type: rj45
  730. speed: 1
  731. count: 1
  732. name: srv-keep
  733. connections:
  734. - a:
  735. resource: srv-keep
  736. portGroup: 0
  737. portIndex: 0
  738. b:
  739. resource: sw-keep
  740. portGroup: 0
  741. portIndex: 0
  742. """;
  743. await client.PostAsJsonAsync("/api/inventory", new { yaml = initial, mode = "Merge" });
  744. // An import that says nothing about connections must not drop them
  745. var resourcesOnly = """
  746. resources:
  747. - kind: Server
  748. name: srv-keep
  749. notes: updated
  750. """;
  751. await client.PostAsJsonAsync("/api/inventory", new { yaml = resourcesOnly, mode = "Merge" });
  752. // The original connection still exists: re-importing the initial
  753. // config reports no connection changes.
  754. HttpResponseMessage check = await client.PostAsJsonAsync("/api/inventory",
  755. new { yaml = initial, dryRun = true, mode = "Merge" });
  756. ImportYamlResponse? result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
  757. Assert.Empty(result!.ConnectionsAdded);
  758. Assert.Empty(result.ConnectionsRemoved);
  759. }
  760. [Fact]
  761. public async Task Merge_Other_Hardware_Persists() {
  762. HttpClient client = CreateClient(true);
  763. var yaml = """
  764. resources:
  765. - kind: Other
  766. name: radio-merge
  767. model: Building Bridge XG
  768. description: Microwave radio bridge
  769. """;
  770. HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
  771. new {
  772. Yaml = yaml,
  773. mode = "Merge"
  774. });
  775. Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  776. ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
  777. Assert.Contains("radio-merge", result!.Added);
  778. var update = """
  779. resources:
  780. - kind: Other
  781. name: radio-merge
  782. description: Site-to-site connectivity
  783. """;
  784. HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
  785. new { yaml = update, mode = "Merge" });
  786. Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
  787. ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
  788. Assert.Contains("radio-merge", result2!.Updated);
  789. var newYaml = result2.NewYaml["radio-merge"];
  790. Assert.Contains("model: Building Bridge XG", newYaml);
  791. Assert.Contains("description: Site-to-site connectivity", newYaml);
  792. }
  793. }