CorruptConfigTests.cs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. using Tests.EndToEnd.Infra;
  2. using Xunit.Abstractions;
  3. namespace Tests.EndToEnd;
  4. // Load-side half of https://github.com/Timmoth/RackPeek/issues/337.
  5. //
  6. // Before the fix a damaged config was accepted without complaint: an unparseable
  7. // file loaded as an EMPTY inventory with exit 0, and the next write persisted that
  8. // emptiness over a recoverable file. These tests pin the contract that a config
  9. // which exists but cannot be understood fails loudly on every read, and — the part
  10. // that actually loses data — is never overwritten.
  11. //
  12. // Note on what is NOT testable here: a save interrupted at a clean resource boundary
  13. // leaves valid YAML that is indistinguishable from a smaller inventory. Nothing at
  14. // load time can detect it, which is precisely why the writer-side fix (atomic,
  15. // durable saves — see PhysicalTextFileStoreTests) is the primary remedy.
  16. [Collection("Yaml CLI tests")]
  17. public class CorruptConfigTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
  18. : IClassFixture<TempYamlCliFixture> {
  19. // Exactly what the serializer writes for three bare servers.
  20. private const string _fullConfig = """
  21. version: 4
  22. resources:
  23. - kind: Server
  24. name: srv-a
  25. - kind: Server
  26. name: srv-b
  27. - kind: Server
  28. name: srv-c
  29. connections: []
  30. """;
  31. private async Task<string> ExecuteAsync(params string[] args) {
  32. outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
  33. var output = await YamlCliTestHost.RunAsync(
  34. args,
  35. fs.Root,
  36. outputHelper,
  37. "config.yaml");
  38. outputHelper.WriteLine(output);
  39. return output;
  40. }
  41. private string ConfigPath => Path.Combine(fs.Root, "config.yaml");
  42. [Fact]
  43. public async Task a_config_cut_mid_token_fails_with_a_friendly_error() {
  44. // A save that died mid-write inside a YAML token.
  45. var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)];
  46. await File.WriteAllTextAsync(ConfigPath, truncated);
  47. var output = await ExecuteAsync("summary");
  48. // An actionable message naming the config file — not a stack dump, and above
  49. // all not a cheerful "Hardware (0)".
  50. Assert.Contains("config.yaml", output);
  51. Assert.DoesNotContain("at RackPeek.", output);
  52. Assert.DoesNotContain("YamlDotNet.Core", output);
  53. Assert.DoesNotContain("Hardware (0)", output);
  54. }
  55. [Fact]
  56. public async Task a_file_that_is_not_a_rackpeek_config_is_rejected() {
  57. // Parses as YAML, carries no schema version: not our document.
  58. await File.WriteAllTextAsync(ConfigPath, "hello: world\n");
  59. var output = await ExecuteAsync("summary");
  60. Assert.Contains("config.yaml", output);
  61. Assert.DoesNotContain("Hardware (0)", output);
  62. }
  63. [Fact]
  64. public async Task a_damaged_config_is_never_overwritten_by_a_later_write() {
  65. // The data-loss path: read the damaged file, then try to write. The write
  66. // must refuse rather than persist the empty in-memory collection over it.
  67. var truncated = _fullConfig[.._fullConfig.IndexOf("nd: Server\n name: srv-c", StringComparison.Ordinal)];
  68. await File.WriteAllTextAsync(ConfigPath, truncated);
  69. var output = await ExecuteAsync("servers", "add", "srv-d");
  70. Assert.DoesNotContain("added", output, StringComparison.OrdinalIgnoreCase);
  71. var onDisk = await File.ReadAllTextAsync(ConfigPath);
  72. Assert.Equal(truncated, onDisk);
  73. }
  74. [Fact]
  75. public async Task an_empty_config_is_still_a_valid_empty_inventory() {
  76. // The file the CLI itself creates on first run. Must not be mistaken for damage.
  77. await File.WriteAllTextAsync(ConfigPath, "");
  78. var output = await ExecuteAsync("servers", "add", "srv-a");
  79. Assert.Contains("added", output, StringComparison.OrdinalIgnoreCase);
  80. Assert.Contains("name: srv-a", await File.ReadAllTextAsync(ConfigPath));
  81. }
  82. [Fact]
  83. public async Task a_legacy_config_without_a_version_key_still_migrates() {
  84. // Pre-v1 files carry no version key at all. The migration chain stamps one,
  85. // so they must not trip the "missing schema version" guard.
  86. await File.WriteAllTextAsync(
  87. ConfigPath,
  88. "resources:\n - kind: Server\n name: legacy-srv\n");
  89. var output = await ExecuteAsync("summary");
  90. Assert.Contains("Server: 1", output);
  91. Assert.Contains("version: 4", await File.ReadAllTextAsync(ConfigPath));
  92. }
  93. [Fact]
  94. public async Task a_healthy_config_still_loads_and_writes() {
  95. await File.WriteAllTextAsync(ConfigPath, _fullConfig);
  96. var output = await ExecuteAsync("summary");
  97. Assert.Contains("Server: 3", output);
  98. output = await ExecuteAsync("servers", "add", "srv-d");
  99. Assert.Contains("added", output, StringComparison.OrdinalIgnoreCase);
  100. Assert.Contains("name: srv-d", await File.ReadAllTextAsync(ConfigPath));
  101. }
  102. }