feat(caddy): support renaming configuration files

This commit is contained in:
2026-07-26 12:55:50 +07:00
parent f5a1f99dec
commit d5ea7b56cb
7 changed files with 350 additions and 27 deletions

View File

@@ -466,6 +466,187 @@ public class CaddyServiceTests : IDisposable
#endregion
#region RenameCaddyConfiguration Tests
/// <summary>
/// Tests that the Caddy service renames an existing configuration file while preserving its content.
/// Setup: Creates a configuration file with known content, then requests a rename to an unused name.
/// Expectation: The service should move the file to the new name, remove the old one and keep the content intact, so a user can correct a configuration's name without losing its reverse proxy definition.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithExistingFile_RenamesSuccessfully()
{
// Arrange
const string content = "example.com {\n reverse_proxy localhost:8080\n}";
File.WriteAllText(Path.Combine(_tempConfigDir, "old-name.caddy"), content);
// Act
var result = _service.RenameCaddyConfiguration("old-name", "new-name");
// Assert
result.Success.Should().BeTrue();
result.Message.Should().Be("Configuration file renamed successfully");
File.Exists(Path.Combine(_tempConfigDir, "old-name.caddy")).Should().BeFalse();
File.ReadAllText(Path.Combine(_tempConfigDir, "new-name.caddy")).Should().Be(content);
}
/// <summary>
/// Tests that the Caddy service refuses to rename a configuration onto a name that is already taken.
/// Setup: Creates two configuration files with distinct content, then attempts to rename the first onto the second.
/// Expectation: The service should fail without touching either file, preventing a rename from silently overwriting an unrelated reverse proxy configuration.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithExistingTarget_ReturnsFailureAndLeavesFilesUntouched()
{
// Arrange
var sourcePath = Path.Combine(_tempConfigDir, "source.caddy");
var targetPath = Path.Combine(_tempConfigDir, "target.caddy");
File.WriteAllText(sourcePath, "source content");
File.WriteAllText(targetPath, "target content");
// Act
var result = _service.RenameCaddyConfiguration("source", "target");
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file already exists");
File.ReadAllText(sourcePath).Should().Be("source content");
File.ReadAllText(targetPath).Should().Be("target content");
}
/// <summary>
/// Tests that the Caddy service reports a failure when the configuration to rename is not present.
/// Setup: Requests a rename for a configuration name that has no file in the configuration directory.
/// Expectation: The service should return a descriptive failure rather than creating anything, so a stale UI listing cannot produce an empty configuration file.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithMissingSource_ReturnsFailure()
{
// Act
var result = _service.RenameCaddyConfiguration("does-not-exist", "new-name");
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file to rename does not exist");
File.Exists(Path.Combine(_tempConfigDir, "new-name.caddy")).Should().BeFalse();
}
/// <summary>
/// Tests that the Caddy service rejects an empty new name for a rename.
/// Setup: Creates a configuration file, then attempts to rename it to whitespace.
/// Expectation: The service should fail and leave the original file in place, since an unnamed configuration file cannot be addressed or loaded by Caddy.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithEmptyNewName_ReturnsFailure()
{
// Arrange
var sourcePath = Path.Combine(_tempConfigDir, "source.caddy");
File.WriteAllText(sourcePath, "content");
// Act
var result = _service.RenameCaddyConfiguration("source", " ");
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file name is required");
File.Exists(sourcePath).Should().BeTrue();
}
/// <summary>
/// Tests that the Caddy service protects the global Caddyfile from being renamed in either direction.
/// Setup: Creates the global Caddyfile plus a regular configuration, then attempts a rename using "Caddyfile" as the source and as the target.
/// Expectation: Both attempts should fail and leave the files untouched, because Caddy loads the global configuration by that exact name and renaming it would break the whole proxy.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithGlobalCaddyfile_ReturnsFailure()
{
// Arrange
var globalPath = Path.Combine(_tempConfigDir, "Caddyfile");
var regularPath = Path.Combine(_tempConfigDir, "regular.caddy");
File.WriteAllText(globalPath, "global content");
File.WriteAllText(regularPath, "regular content");
// Act
var renameGlobalAway = _service.RenameCaddyConfiguration("Caddyfile", "something-else");
var renameOntoGlobal = _service.RenameCaddyConfiguration("regular", "Caddyfile");
// Assert
renameGlobalAway.Success.Should().BeFalse();
renameGlobalAway.Message.Should().Be("The global Caddyfile cannot be renamed");
renameOntoGlobal.Success.Should().BeFalse();
renameOntoGlobal.Message.Should().Be("The global Caddyfile cannot be renamed");
File.ReadAllText(globalPath).Should().Be("global content");
File.ReadAllText(regularPath).Should().Be("regular content");
}
/// <summary>
/// Tests that the Caddy service treats a rename to the unchanged name as a successful no-op.
/// Setup: Creates a configuration file and requests a rename to the exact same name.
/// Expectation: The service should succeed and leave the file as is, letting the editor call rename unconditionally on save without special-casing an untouched name field.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithUnchangedName_SucceedsAsNoOp()
{
// Arrange
var filePath = Path.Combine(_tempConfigDir, "same-name.caddy");
File.WriteAllText(filePath, "content");
// Act
var result = _service.RenameCaddyConfiguration("same-name", "same-name");
// Assert
result.Success.Should().BeTrue();
File.ReadAllText(filePath).Should().Be("content");
}
/// <summary>
/// Tests that the Caddy service allows a rename that only changes letter casing.
/// Setup: Creates a lowercase configuration file, then renames it to the same name with different casing.
/// Expectation: The service should succeed and the content should be reachable under the new casing, since on case insensitive file systems a naive collision check would otherwise see the source file as an existing target and reject a legitimate rename.
/// </summary>
[Fact]
public void RenameCaddyConfiguration_WithCaseOnlyChange_RenamesSuccessfully()
{
// Arrange
File.WriteAllText(Path.Combine(_tempConfigDir, "myapp.caddy"), "content");
// Act
var result = _service.RenameCaddyConfiguration("myapp", "MyApp");
// Assert
result.Success.Should().BeTrue();
File.ReadAllText(Path.Combine(_tempConfigDir, "MyApp.caddy")).Should().Be("content");
_service.GetExistingCaddyConfigurations().Select(c => c.FileName).Should().BeEquivalentTo(["MyApp"]);
}
/// <summary>
/// Tests that the Caddy service rejects rename targets that would escape the configuration directory or use characters the file system disallows.
/// Setup: Creates a configuration file, then attempts renames using a path traversal segment, a directory separator and an invalid file name character.
/// Expectation: Every attempt should fail with an invalid characters message and write nothing outside the configuration directory, since the new name arrives from user input and is used directly to build a file path.
/// </summary>
[Theory]
[InlineData("../escaped")]
[InlineData("nested/name")]
[InlineData("invalid\0name")]
public void RenameCaddyConfiguration_WithUnsafeNewName_ReturnsFailure(string newFileName)
{
// Arrange
var sourcePath = Path.Combine(_tempConfigDir, "source.caddy");
File.WriteAllText(sourcePath, "content");
// Act
var result = _service.RenameCaddyConfiguration("source", newFileName);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file name contains invalid characters");
File.ReadAllText(sourcePath).Should().Be("content");
Directory.GetFiles(_tempConfigDir).Should().HaveCount(1);
File.Exists(Path.Combine(_tempConfigDir, "..", "escaped.caddy")).Should().BeFalse();
}
#endregion
#region DeleteCaddyConfigurations Tests
/// <summary>