MutationToolTests.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. using ModelContextProtocol.Client;
  2. using RackPeek.Domain.Api;
  3. using RackPeek.Domain.Discovery;
  4. using RackPeek.Domain.Resources.Connections;
  5. using RackPeek.Mcp.Tools;
  6. namespace Tests.Mcp;
  7. /// <summary>
  8. /// The write half of the tool surface. Every assertion here is made against what
  9. /// actually lands in config.yaml — the file is the product, not the tool response.
  10. /// </summary>
  11. public class MutationToolTests {
  12. private const string _newServerYaml =
  13. """
  14. version: 4
  15. resources:
  16. - kind: Server
  17. name: new-server
  18. ram:
  19. size: 64
  20. ports:
  21. - type: rj45
  22. speed: 1
  23. count: 2
  24. """;
  25. // -- upsert_resources ----------------------------------------------------------------
  26. [Fact]
  27. public async Task Upserting_a_new_resource_persists_it_as_schema_conformant_yaml() {
  28. using var api = new McpFixture(TestData.Seed);
  29. await using McpClient client = await api.ConnectAsync();
  30. ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
  31. "upsert_resources", new Dictionary<string, object?> { ["yaml"] = _newServerYaml });
  32. Assert.Equal(["new-server"], response.Added);
  33. Assert.Contains("new-server", api.StoredYaml);
  34. SchemaAssert.ConformsToSchema(api.StoredYaml);
  35. }
  36. [Fact]
  37. public async Task A_dry_run_reports_the_diff_but_writes_nothing() {
  38. using var api = new McpFixture(TestData.Seed);
  39. await using McpClient client = await api.ConnectAsync();
  40. var before = api.StoredYaml;
  41. ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
  42. "upsert_resources",
  43. new Dictionary<string, object?> { ["yaml"] = _newServerYaml, ["dryRun"] = true });
  44. Assert.Equal(["new-server"], response.Added);
  45. Assert.Contains("new-server", Assert.Contains("new-server", response.NewYaml));
  46. Assert.Equal(before, api.StoredYaml);
  47. }
  48. [Fact]
  49. public async Task Discovery_name_improvement_is_previewed_without_writing_then_persisted_by_upsert() {
  50. var id = DiscoveryId.Create(DiscoveryId.NetworkScheme, "192.0.2.78");
  51. var placeholder = $"host-{DiscoveryId.ShortSuffix(id)}";
  52. var oldServiceName = $"{placeholder}-ssh";
  53. var seed = $$"""
  54. version: 4
  55. resources:
  56. - kind: System
  57. name: {{placeholder}}
  58. discoveryId: {{id}}
  59. ip: 192.0.2.78
  60. - kind: Service
  61. name: {{oldServiceName}}
  62. discoveryId: rpk1:docker:1359175a57049554
  63. runsOn:
  64. - {{placeholder}}
  65. - kind: Switch
  66. name: core-switch
  67. ports:
  68. - type: rj45
  69. speed: 1
  70. count: 2
  71. connections:
  72. - a:
  73. resource: {{placeholder}}
  74. portGroup: 0
  75. portIndex: 0
  76. b:
  77. resource: core-switch
  78. portGroup: 0
  79. portIndex: 0
  80. """;
  81. using var api = new McpFixture(seed);
  82. await using McpClient client = await api.ConnectAsync();
  83. var before = api.StoredYaml;
  84. var update = $$"""
  85. resources:
  86. - kind: System
  87. name: forgejo
  88. discoveryId: {{id}}
  89. """;
  90. ImportYamlResponse preview = await client.CallOkAsync<ImportYamlResponse>(
  91. "upsert_resources", new Dictionary<string, object?> {
  92. ["yaml"] = update,
  93. ["dryRun"] = true
  94. });
  95. Assert.Equal("forgejo", preview.Renamed[placeholder]);
  96. Assert.Equal("forgejo-ssh", preview.Renamed[oldServiceName]);
  97. Assert.Contains(preview.ConnectionsAdded, description => description.Contains("forgejo"));
  98. Assert.Contains(preview.ConnectionsRemoved, description => description.Contains(placeholder));
  99. Assert.Equal(before, api.StoredYaml);
  100. ImportYamlResponse applied = await client.CallOkAsync<ImportYamlResponse>(
  101. "upsert_resources", new Dictionary<string, object?> { ["yaml"] = update });
  102. Assert.Equal(preview.Renamed, applied.Renamed);
  103. Assert.Equal(preview.Updated, applied.Updated);
  104. Assert.Equal(preview.NewYaml, applied.NewYaml);
  105. Assert.Contains("name: forgejo", api.StoredYaml);
  106. Assert.Contains("name: forgejo-ssh", api.StoredYaml);
  107. Assert.Contains("runsOn:", api.StoredYaml);
  108. Assert.Contains("- forgejo", api.StoredYaml);
  109. Assert.Contains("resource: forgejo", api.StoredYaml);
  110. }
  111. [Fact]
  112. public async Task Merge_updates_fields_and_reports_old_and_new_yaml() {
  113. using var api = new McpFixture(TestData.Seed);
  114. await using McpClient client = await api.ConnectAsync();
  115. ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
  116. "upsert_resources", new Dictionary<string, object?> {
  117. ["yaml"] =
  118. """
  119. version: 4
  120. resources:
  121. - kind: System
  122. name: host-os
  123. cores: 16
  124. """
  125. });
  126. Assert.Equal(["host-os"], response.Updated);
  127. Assert.Contains("cores: 8", response.OldYaml["host-os"]);
  128. Assert.Contains("cores: 16", response.NewYaml["host-os"]);
  129. Assert.Contains("cores: 16", api.StoredYaml);
  130. // Merge only adds and updates — everything not mentioned stays.
  131. Assert.Contains("env: prod", api.StoredYaml);
  132. Assert.Contains("ip: 10.0.0.5", api.StoredYaml);
  133. }
  134. [Fact]
  135. public async Task Replace_mode_swaps_the_resource_in_wholesale() {
  136. using var api = new McpFixture(TestData.Seed);
  137. await using McpClient client = await api.ConnectAsync();
  138. ImportYamlResponse response = await client.CallOkAsync<ImportYamlResponse>(
  139. "upsert_resources", new Dictionary<string, object?> {
  140. ["yaml"] =
  141. """
  142. version: 4
  143. resources:
  144. - kind: System
  145. name: host-os
  146. type: vm
  147. os: alpine
  148. """,
  149. ["mode"] = "Replace"
  150. });
  151. Assert.Equal(["host-os"], response.Replaced);
  152. Assert.Contains("os: alpine", api.StoredYaml);
  153. Assert.DoesNotContain("env: prod", api.StoredYaml); // replaced, so the old labels are gone
  154. // Replace swaps the named resource only; the rest of the file is untouched.
  155. Assert.Contains("grafana", api.StoredYaml);
  156. }
  157. [Theory]
  158. [InlineData("not: [valid", "Import failed")]
  159. [InlineData("version: 4", "resources")]
  160. [InlineData("", "Invalid input")]
  161. public async Task Broken_documents_are_errors_that_name_the_problem(string yaml, string expected) {
  162. using var api = new McpFixture(TestData.Seed);
  163. await using McpClient client = await api.ConnectAsync();
  164. var before = api.StoredYaml;
  165. var error = await client.CallErrorAsync(
  166. "upsert_resources", new Dictionary<string, object?> { ["yaml"] = yaml });
  167. Assert.Contains(expected, error);
  168. Assert.Equal(before, api.StoredYaml);
  169. }
  170. [Fact]
  171. public async Task The_first_mcp_write_after_a_restart_does_not_destroy_the_existing_inventory() {
  172. // Same guarantee the inventory API pins down: the server loads the config before
  173. // serving, so a fresh boot's first merge happens against the user's file rather
  174. // than an empty collection (which would persist and wipe everything else).
  175. using var api = new McpFixture(TestData.Seed);
  176. await using McpClient client = await api.ConnectAsync();
  177. await client.CallOkAsync<ImportYamlResponse>(
  178. "upsert_resources", new Dictionary<string, object?> { ["yaml"] = _newServerYaml });
  179. Assert.Contains("rack-server", api.StoredYaml);
  180. Assert.Contains("grafana", api.StoredYaml);
  181. Assert.Contains("new-server", api.StoredYaml);
  182. }
  183. // -- delete_resource -----------------------------------------------------------------
  184. [Fact]
  185. public async Task Deleting_hardware_detaches_dependants_and_unplugs_its_connections() {
  186. using var api = new McpFixture(TestData.Seed);
  187. await using McpClient client = await api.ConnectAsync();
  188. await client.CallTextAsync(
  189. "delete_resource", new Dictionary<string, object?> { ["name"] = "rack-server" });
  190. var stored = api.StoredYaml;
  191. Assert.DoesNotContain("rack-server", stored);
  192. SchemaAssert.ConformsToSchema(stored);
  193. ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
  194. Assert.Equal(0, connections.Count);
  195. ResourceDetail hostOs = await client.CallOkAsync<ResourceDetail>(
  196. "get_resource", new Dictionary<string, object?> { ["name"] = "host-os" });
  197. Assert.DoesNotContain("rack-server", hostOs.Yaml);
  198. }
  199. [Fact]
  200. public async Task Deleting_something_that_does_not_exist_is_an_error() {
  201. using var api = new McpFixture(TestData.Seed);
  202. await using McpClient client = await api.ConnectAsync();
  203. var error = await client.CallErrorAsync(
  204. "delete_resource", new Dictionary<string, object?> { ["name"] = "ghost" });
  205. Assert.Contains("ghost", error);
  206. }
  207. // -- rename_resource -----------------------------------------------------------------
  208. [Fact]
  209. public async Task Renaming_rewrites_runs_on_links_and_connection_endpoints() {
  210. using var api = new McpFixture(TestData.Seed);
  211. await using McpClient client = await api.ConnectAsync();
  212. await client.CallTextAsync("rename_resource", new Dictionary<string, object?> {
  213. ["name"] = "rack-server",
  214. ["newName"] = "compute-01"
  215. });
  216. var stored = api.StoredYaml;
  217. Assert.DoesNotContain("rack-server", stored);
  218. SchemaAssert.ConformsToSchema(stored);
  219. ResourceDetail hostOs = await client.CallOkAsync<ResourceDetail>(
  220. "get_resource", new Dictionary<string, object?> { ["name"] = "host-os" });
  221. Assert.Contains("compute-01", hostOs.Yaml);
  222. ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
  223. Assert.Equal("compute-01", Assert.Single(connections.Connections).A.Resource);
  224. }
  225. [Fact]
  226. public async Task Renaming_onto_a_taken_name_is_a_conflict() {
  227. using var api = new McpFixture(TestData.Seed);
  228. await using McpClient client = await api.ConnectAsync();
  229. var error = await client.CallErrorAsync("rename_resource", new Dictionary<string, object?> {
  230. ["name"] = "rack-server",
  231. ["newName"] = "rack-switch"
  232. });
  233. Assert.Contains("already exists", error);
  234. }
  235. // -- clone_resource ------------------------------------------------------------------
  236. [Fact]
  237. public async Task A_clone_copies_the_kind_specific_fields_but_never_the_discovery_id() {
  238. using var api = new McpFixture(TestData.Seed);
  239. await using McpClient client = await api.ConnectAsync();
  240. await client.CallTextAsync("clone_resource", new Dictionary<string, object?> {
  241. ["name"] = "rack-server",
  242. ["cloneName"] = "compute-02"
  243. });
  244. ResourceDetail clone = await client.CallOkAsync<ResourceDetail>(
  245. "get_resource", new Dictionary<string, object?> { ["name"] = "compute-02" });
  246. Assert.Contains("kind: Server", clone.Yaml);
  247. // The deep copy went through the concrete Server type: the ports survived.
  248. Assert.Contains("ports:", clone.Yaml);
  249. Assert.Contains("count: 4", clone.Yaml);
  250. // A discoveryId names one machine; a copy of its card is not that machine.
  251. Assert.DoesNotContain("discoveryId", clone.Yaml);
  252. SchemaAssert.ConformsToSchema(api.StoredYaml);
  253. }
  254. [Fact]
  255. public async Task Cloning_guards_both_names() {
  256. using var api = new McpFixture(TestData.Seed);
  257. await using McpClient client = await api.ConnectAsync();
  258. Assert.Contains("not found", await client.CallErrorAsync("clone_resource",
  259. new Dictionary<string, object?> { ["name"] = "ghost", ["cloneName"] = "copy" }));
  260. Assert.Contains("already exists", await client.CallErrorAsync("clone_resource",
  261. new Dictionary<string, object?> { ["name"] = "rack-server", ["cloneName"] = "rack-switch" }));
  262. }
  263. // -- edit_tags / edit_labels ---------------------------------------------------------
  264. [Fact]
  265. public async Task Tags_can_be_added_and_removed_in_one_call() {
  266. using var api = new McpFixture(TestData.Seed);
  267. await using McpClient client = await api.ConnectAsync();
  268. TagsResult result = await client.CallOkAsync<TagsResult>("edit_tags",
  269. new Dictionary<string, object?> {
  270. ["name"] = "rack-server",
  271. ["add"] = new[] { "rack-a", "critical" },
  272. ["remove"] = new[] { "prod" }
  273. });
  274. Assert.Equal(["rack-a", "critical"], result.Tags);
  275. Assert.Contains("rack-a", api.StoredYaml);
  276. Assert.DoesNotContain("- prod", api.StoredYaml);
  277. }
  278. [Fact]
  279. public async Task Labels_can_be_set_overwritten_and_removed() {
  280. using var api = new McpFixture(TestData.Seed);
  281. await using McpClient client = await api.ConnectAsync();
  282. LabelsResult result = await client.CallOkAsync<LabelsResult>("edit_labels",
  283. new Dictionary<string, object?> {
  284. ["name"] = "host-os",
  285. ["set"] = new Dictionary<string, string> { ["env"] = "staging", ["owner"] = "tim" },
  286. ["remove"] = new[] { "owner" }
  287. });
  288. Assert.Equal("staging", Assert.Contains("env", result.Labels));
  289. Assert.DoesNotContain("owner", result.Labels.Keys);
  290. Assert.Contains("env: staging", api.StoredYaml);
  291. }
  292. [Fact]
  293. public async Task Editing_tags_or_labels_needs_something_to_do() {
  294. using var api = new McpFixture(TestData.Seed);
  295. await using McpClient client = await api.ConnectAsync();
  296. Assert.Contains("at least one", await client.CallErrorAsync("edit_tags",
  297. new Dictionary<string, object?> { ["name"] = "rack-server" }));
  298. Assert.Contains("at least one", await client.CallErrorAsync("edit_labels",
  299. new Dictionary<string, object?> { ["name"] = "host-os" }));
  300. }
  301. // -- add_connection / remove_connection ------------------------------------------------
  302. private static Dictionary<string, object?> Connect(
  303. string a, int groupA, int indexA, string b, int groupB, int indexB) => new() {
  304. ["resourceA"] = a,
  305. ["portGroupA"] = groupA,
  306. ["portIndexA"] = indexA,
  307. ["resourceB"] = b,
  308. ["portGroupB"] = groupB,
  309. ["portIndexB"] = indexB
  310. };
  311. [Fact]
  312. public async Task Connecting_two_free_ports_is_persisted() {
  313. using var api = new McpFixture(TestData.Seed);
  314. await using McpClient client = await api.ConnectAsync();
  315. await client.CallTextAsync("add_connection",
  316. Connect("rack-server", 0, 1, "rack-switch", 0, 2));
  317. ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
  318. Assert.Equal(2, connections.Count);
  319. SchemaAssert.ConformsToSchema(api.StoredYaml);
  320. }
  321. [Fact]
  322. public async Task Connecting_an_occupied_port_replaces_what_was_plugged_into_it() {
  323. using var api = new McpFixture(TestData.Seed);
  324. await using McpClient client = await api.ConnectAsync();
  325. // rack-server port 0/0 is already cabled to rack-switch 0/0 in the seed.
  326. await client.CallTextAsync("add_connection",
  327. Connect("rack-server", 0, 0, "rack-switch", 0, 5));
  328. ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
  329. Connection connection = Assert.Single(connections.Connections);
  330. Assert.Equal(5, connection.B.PortIndex);
  331. }
  332. [Fact]
  333. public async Task Impossible_connections_are_named_errors() {
  334. using var api = new McpFixture(TestData.Seed);
  335. await using McpClient client = await api.ConnectAsync();
  336. Assert.Contains("itself", await client.CallErrorAsync("add_connection",
  337. Connect("rack-server", 0, 0, "rack-server", 0, 0)));
  338. Assert.Contains("no ports", await client.CallErrorAsync("add_connection",
  339. Connect("host-os", 0, 0, "rack-switch", 0, 1)));
  340. Assert.Contains("not found", await client.CallErrorAsync("add_connection",
  341. Connect("rack-server", 0, 99, "rack-switch", 0, 1)));
  342. }
  343. [Fact]
  344. public async Task Removing_a_connection_unplugs_the_port_and_removing_again_is_a_no_op() {
  345. using var api = new McpFixture(TestData.Seed);
  346. await using McpClient client = await api.ConnectAsync();
  347. var args = new Dictionary<string, object?> {
  348. ["resource"] = "rack-switch",
  349. ["portGroup"] = 0,
  350. ["portIndex"] = 0
  351. };
  352. await client.CallTextAsync("remove_connection", args);
  353. await client.CallTextAsync("remove_connection", args); // idempotent
  354. ConnectionList connections = await client.CallOkAsync<ConnectionList>("list_connections");
  355. Assert.Equal(0, connections.Count);
  356. Assert.DoesNotContain("portIndex", api.StoredYaml);
  357. }
  358. }