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:
Joseph Doherty
2026-05-11 21:29:21 -04:00
parent 8e388a89c5
commit b4cb7e6f5f
3 changed files with 43 additions and 42 deletions

View File

@@ -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]