feat(templates): lock ParentTemplateId after creation
Template inheritance is set once at create time and immutable on update. UpdateTemplateAsync now returns "Parent template cannot be changed after creation." when the caller sends a parent that differs from the stored value — server-side enforcement covers UI, ManagementService, and CLI. TemplateEdit renders the parent as static plaintext rather than an editable dropdown; TemplateCreate's parent picker is unchanged.
This commit is contained in:
@@ -468,35 +468,50 @@ public class TemplateServiceTests
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_InheritanceCycle_Fails()
|
||||
public async Task UpdateTemplate_ChangeParent_Fails()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||
var parentA = new Template("A") { Id = 1 };
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(templateB);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { templateA, templateB });
|
||||
|
||||
// Try to make A inherit from B (B already inherits from A) => cycle
|
||||
var result = await _service.UpdateTemplateAsync(1, "A", null, 2, "admin");
|
||||
// Attempt to re-parent Child from A (id=1) to B (id=3).
|
||||
var result = await _service.UpdateTemplateAsync(2, "Child", null, 3, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_SelfInheritance_Fails()
|
||||
public async Task UpdateTemplate_ClearParent_Fails()
|
||||
{
|
||||
var template = new Template("Self") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||
|
||||
var result = await _service.UpdateTemplateAsync(1, "Self", null, 1, "admin");
|
||||
// Attempt to clear the parent.
|
||||
var result = await _service.UpdateTemplateAsync(2, "Child", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("itself", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_SameParent_Succeeds()
|
||||
{
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "old" };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { child });
|
||||
|
||||
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
|
||||
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("ChildRenamed", result.Value.Name);
|
||||
Assert.Equal("new", result.Value.Description);
|
||||
Assert.Equal(1, result.Value.ParentTemplateId);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user