From 1e7597e4d6e702e27ab540549e0fde798d575692 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 2 Jul 2026 19:23:23 -0700 Subject: [PATCH] VPR-59 feat(cms): content blocks, left navigation, and link collections API Slice 2/3 of the restacked CMS migration (tip file states). Content blocks with version history, diffs, and the 409 stale-edit guard; left-nav menu and item management with display-parity filtering; link-collection fixes; and their unit tests. Stacks on the files backend; the management SPA follows in slice 3. --- test/CMS/CMSContentControllerTests.cs | 505 ++++++++++++++ test/CMS/CMSLeftNavControllerTests.cs | 211 ++++++ test/CMS/CMSLinkCollectionControllerTests.cs | 383 ++++++++++ test/CMS/CMSLinkCollectionLinksTests.cs | 407 +++++++++++ test/CMS/CmsContentBlockServiceTests.cs | 556 +++++++++++++++ test/CMS/CmsLeftNavDisplayTests.cs | 114 +++ test/CMS/CmsLeftNavServiceTests.cs | 347 ++++++++++ .../CMS/Controllers/CMSContentController.cs | 358 ++++++---- .../CMS/Controllers/CMSLeftNavController.cs | 121 ++++ .../CMSLinkCollectionController.cs | 77 ++- .../CMS/Controllers/CMSLinkCollectionLinks.cs | 12 +- web/Areas/CMS/Data/LeftNavMenu.cs | 16 +- web/Areas/CMS/Models/CMSBlockAddEdit.cs | 8 + web/Areas/CMS/Models/CmsContentBlockMapper.cs | 36 + web/Areas/CMS/Models/DTOs/ContentBlockDto.cs | 89 +++ web/Areas/CMS/Models/DTOs/LeftNavDtos.cs | 102 +++ .../CMS/Services/CmsContentBlockService.cs | 653 ++++++++++++++++++ web/Areas/CMS/Services/CmsLeftNavService.cs | 321 +++++++++ web/Areas/CMS/Services/CmsNavMenu.cs | 49 +- 19 files changed, 4207 insertions(+), 158 deletions(-) create mode 100644 test/CMS/CMSContentControllerTests.cs create mode 100644 test/CMS/CMSLeftNavControllerTests.cs create mode 100644 test/CMS/CMSLinkCollectionControllerTests.cs create mode 100644 test/CMS/CMSLinkCollectionLinksTests.cs create mode 100644 test/CMS/CmsContentBlockServiceTests.cs create mode 100644 test/CMS/CmsLeftNavDisplayTests.cs create mode 100644 test/CMS/CmsLeftNavServiceTests.cs create mode 100644 web/Areas/CMS/Controllers/CMSLeftNavController.cs create mode 100644 web/Areas/CMS/Models/CmsContentBlockMapper.cs create mode 100644 web/Areas/CMS/Models/DTOs/ContentBlockDto.cs create mode 100644 web/Areas/CMS/Models/DTOs/LeftNavDtos.cs create mode 100644 web/Areas/CMS/Services/CmsContentBlockService.cs create mode 100644 web/Areas/CMS/Services/CmsLeftNavService.cs diff --git a/test/CMS/CMSContentControllerTests.cs b/test/CMS/CMSContentControllerTests.cs new file mode 100644 index 000000000..fa3dcab9d --- /dev/null +++ b/test/CMS/CMSContentControllerTests.cs @@ -0,0 +1,505 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using NSubstitute.ExceptionExtensions; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Controllers; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models; +using Viper.Models.AAUD; +using Viper.Services; + +namespace Viper.test.CMS; + +/// +/// Controller wiring tests for CMSContentController: the action layer delegates to +/// ICmsContentBlockService and maps results to status codes (404 on null, 409 on +/// concurrency, 400 on validation). The new history-list and version-diff endpoints +/// are covered for passthrough and 404-on-null only; the query/diff logic itself lives +/// in CmsContentBlockServiceTests. Permanent delete is admin-gated. +/// +public sealed class CMSContentControllerTests : IDisposable +{ + private readonly ICmsContentBlockService _blockService; + private readonly ICmsFileStorageService _storage; + private readonly IUserHelper _userHelper; + private readonly VIPERContext _context; + private readonly RAPSContext _rapsContext; + private readonly CMSContentController _controller; + + public CMSContentControllerTests() + { + _blockService = Substitute.For(); + _storage = Substitute.For(); + _userHelper = Substitute.For(); + var sanitizer = Substitute.For(); + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _rapsContext = new RAPSContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options); + + _controller = new CMSContentController(_context, _rapsContext, sanitizer, _blockService, _storage, _userHelper); + SetupControllerContext(); + } + + public void Dispose() + { + _context.Dispose(); + _rapsContext.Dispose(); + } + + private void SetupControllerContext() + { + // ValidationProblem(string) resolves ProblemDetailsFactory from RequestServices, so a + // stub is registered; otherwise the call throws before producing the ObjectResult. + var services = new ServiceCollection(); + services.AddSingleton(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() } + }; + } + + private sealed class StubProblemDetailsFactory : ProblemDetailsFactory + { + public override ProblemDetails CreateProblemDetails(HttpContext httpContext, int? statusCode = null, + string? title = null, string? type = null, string? detail = null, string? instance = null) + => new() { Status = statusCode, Title = title, Detail = detail }; + + public override ValidationProblemDetails CreateValidationProblemDetails(HttpContext httpContext, + ModelStateDictionary modelStateDictionary, int? statusCode = null, string? title = null, + string? type = null, string? detail = null, string? instance = null) + => new(modelStateDictionary) { Status = statusCode, Title = title, Detail = detail }; + } + + private static ContentBlockDto Block(int id = 1) => new() + { + ContentBlockId = id, + Title = "Block", + System = "Viper", + Content = "

x

" + }; + + private static CMSBlockAddEdit Request(int id = 1) => new() + { + ContentBlockId = id, + Title = "Block", + System = "Viper", + Content = "

x

", + AllowPublicAccess = false, + LastModifiedOn = DateTime.Now + }; + + #region List / Get + + [Fact] + public async Task GetContentBlocks_PassesFiltersThrough() + { + var blocks = new List { Block() }; + _blockService.GetContentBlocksAsync("deleted", "Viper", "cats", "search", true, 1, 50, "title", false, + Arg.Any()).Returns((blocks, 1)); + + var result = await _controller.GetContentBlocks(null, "deleted", "Viper", "cats", "search", true, + ct: TestContext.Current.CancellationToken); + + Assert.Same(blocks, result.Value); + await _blockService.Received(1).GetContentBlocksAsync("deleted", "Viper", "cats", "search", true, 1, 50, + "title", false, Arg.Any()); + } + + [Fact] + public void GetFolders_ReturnsTopLevelUploadFolders() + { + var folders = new List { "accreditation", "students" }; + _storage.GetTopLevelFolders().Returns(folders); + + var result = _controller.GetFolders(); + + Assert.Same(folders, result.Value); + _storage.Received(1).GetTopLevelFolders(); + } + + [Fact] + public async Task GetContentBlock_ReturnsBlock_WhenFound() + { + _blockService.GetContentBlockAsync(5, Arg.Any()).Returns(Block(5)); + + var result = await _controller.GetContentBlock(5, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(5, result.Value!.ContentBlockId); + } + + [Fact] + public async Task GetContentBlock_ReturnsNotFound_WhenMissing() + { + _blockService.GetContentBlockAsync(999, Arg.Any()).ReturnsNull(); + + var result = await _controller.GetContentBlock(999, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public void GetContentBlockByFn_ReturnsNotFound_WhenMissing() + { + var result = _controller.GetContentBlockByFn("does-not-exist"); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetContentBlockByFn_ProjectsToDto_AndDoesNotLeakEntityInternals() + { + // Regression: this endpoint is anonymous. It must project to a DTO and never serialize the + // raw ContentBlock graph, which would leak each attached file's AES Key and server FilePath, + // the full content history, and the block's permission rows. + var file = new Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FriendlyName = "attached-doc", + FilePath = @"S:\Files\secret\attached-doc.pdf", + Key = "SUPER_SECRET_AES_KEY", + Encrypted = true, + AllowPublicAccess = true, + Description = "internal notes", + ModifiedBy = "author" + }; + var block = new Models.VIPER.ContentBlock + { + Content = "

body

", + Title = "Public Block", + System = "Viper", + FriendlyName = "public-fn", + AllowPublicAccess = true, + ModifiedOn = DateTime.Now, + ModifiedBy = "author", + ContentBlockToFiles = { new Models.VIPER.ContentBlockToFile { FileGuid = file.FileGuid, File = file } }, + ContentHistories = { new Models.VIPER.ContentHistory { ContentBlockContent = "

OLD SECRET VERSION

", ModifiedOn = DateTime.Now.AddDays(-1), ModifiedBy = "author" } } + }; + _context.Files.Add(file); + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = _controller.GetContentBlockByFn("public-fn"); + + Assert.NotNull(result.Value); + var json = System.Text.Json.JsonSerializer.Serialize(result.Value, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + // Consumer contract (VueApp ContentBlock type) preserved. + Assert.Contains("\"contentBlockId\"", json); + Assert.Contains("\"content\"", json); + Assert.Contains("\"title\"", json); + // No entity internals or secrets. + Assert.DoesNotContain("\"key\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("filePath", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("contentHistories", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SUPER_SECRET_AES_KEY", json); + Assert.DoesNotContain(@"S:\Files", json); + // No management metadata: the anonymous endpoint must not disclose editor login ids, + // permission names, or placement fields (PublicContentBlockDto, not ContentBlockDto). + Assert.DoesNotContain("modifiedBy", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("permissions", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"system\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("viperSectionPath", json, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Create + + [Fact] + public async Task CreateContentBlock_ReturnsBadRequest_WhenTitleAndSystemMissing() + { + var request = new CMSBlockAddEdit { ContentBlockId = 0, AllowPublicAccess = false, Content = "x", Title = "", System = "" }; + + var result = await _controller.CreateContentBlock(request, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result.Result); + Assert.Contains("Title is required", badRequest.Value?.ToString()); + Assert.Contains("System is required", badRequest.Value?.ToString()); + await _blockService.DidNotReceive().CreateContentBlockAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateContentBlock_ReturnsBlock_OnSuccess() + { + var request = Request(0); + _blockService.CreateContentBlockAsync(request, Arg.Any()).Returns(Block(7)); + + var result = await _controller.CreateContentBlock(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(7, result.Value!.ContentBlockId); + } + + [Fact] + public async Task CreateContentBlock_ReturnsValidationProblem_OnArgumentException() + { + var request = Request(0); + _blockService.CreateContentBlockAsync(request, Arg.Any()) + .Throws(new ArgumentException("Friendly name already in use")); + + var result = await _controller.CreateContentBlock(request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + var problem = (ObjectResult)result.Result!; + Assert.IsType(problem.Value); + } + + #endregion + + #region Update + + [Fact] + public async Task UpdateContentBlock_ReturnsBadRequest_WhenRouteIdMismatch() + { + var request = Request(2); + + var result = await _controller.UpdateContentBlock(1, request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateContentBlock_ReturnsBlock_OnSuccess() + { + var request = Request(3); + _blockService.UpdateContentBlockAsync(3, request, Arg.Any()).Returns(Block(3)); + + var result = await _controller.UpdateContentBlock(3, request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(3, result.Value!.ContentBlockId); + } + + [Fact] + public async Task UpdateContentBlock_ReturnsNotFound_WhenServiceReturnsNull() + { + var request = Request(3); + _blockService.UpdateContentBlockAsync(3, request, Arg.Any()).ReturnsNull(); + + var result = await _controller.UpdateContentBlock(3, request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateContentBlock_ReturnsConflict_OnConcurrencyException() + { + var request = Request(3); + _blockService.UpdateContentBlockAsync(3, request, Arg.Any()) + .Throws(new CmsConcurrencyException("stale")); + + var result = await _controller.UpdateContentBlock(3, request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateContentOnly_ReturnsBlock_OnSuccess() + { + var update = new ContentOnlyUpdate { Content = "

quick

", LastModifiedOn = DateTime.Now }; + _blockService.UpdateContentOnlyAsync(4, update.Content, update.LastModifiedOn, Arg.Any()) + .Returns(Block(4)); + + var result = await _controller.UpdateContentOnly(4, update, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + await _blockService.Received(1).UpdateContentOnlyAsync(4, update.Content, update.LastModifiedOn, + Arg.Any()); + } + + [Fact] + public async Task UpdateContentOnly_ReturnsConflict_OnConcurrencyException() + { + var update = new ContentOnlyUpdate { Content = "

quick

", LastModifiedOn = DateTime.Now }; + _blockService.UpdateContentOnlyAsync(4, update.Content, update.LastModifiedOn, Arg.Any()) + .Throws(new CmsConcurrencyException("stale")); + + var result = await _controller.UpdateContentOnly(4, update, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region History list + version + diff + + [Fact] + public async Task GetHistory_PassesBlockIdThrough() + { + var history = new List { new() { ContentHistoryId = 1 } }; + _blockService.GetHistoryAsync(5, Arg.Any()).Returns(history); + + var result = await _controller.GetHistory(5, TestContext.Current.CancellationToken); + + Assert.Same(history, result.Value); + } + + [Fact] + public async Task GetHistoryVersion_ReturnsNotFound_WhenMissing() + { + _blockService.GetHistoryVersionAsync(5, 12, Arg.Any()).ReturnsNull(); + + var result = await _controller.GetHistoryVersion(5, 12, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetHistoryEntries_PassesFilterAndPaginationThrough_AndSetsTotal() + { + var entries = new List { new() { ContentHistoryId = 1 } }; + _blockService.GetHistoryEntriesAsync(Arg.Any(), 2, 25, Arg.Any()) + .Returns(entries); + _blockService.GetHistoryEntryCountAsync(Arg.Any(), Arg.Any()) + .Returns(99); + var pagination = new ApiPagination { Page = 2, PerPage = 25 }; + var from = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local); + var to = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Local); + + var result = await _controller.GetHistoryEntries(7, "editorX", from, to, "needle", pagination, TestContext.Current.CancellationToken); + + Assert.Same(entries, result.Value); + Assert.Equal(99, pagination.TotalRecords); + await _blockService.Received(1).GetHistoryEntriesAsync( + Arg.Is(f => + f.ContentBlockId == 7 && f.ModifiedBy == "editorX" && f.From == from && f.To == to && f.Search == "needle"), + 2, 25, Arg.Any()); + } + + [Fact] + public async Task GetHistoryEntries_DefaultsPageAndPerPage_AndSkipsCount_WhenNoPagination() + { + _blockService.GetHistoryEntriesAsync(Arg.Any(), 1, 50, Arg.Any()) + .Returns(new List()); + + await _controller.GetHistoryEntries(null, null, null, null, null, null, TestContext.Current.CancellationToken); + + await _blockService.Received(1).GetHistoryEntriesAsync(Arg.Any(), 1, 50, + Arg.Any()); + await _blockService.DidNotReceive().GetHistoryEntryCountAsync(Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetHistoryVersionDiff_PassesThrough_AndReturnsDto() + { + var diff = new ContentHistoryDiffDto { HasComparison = true, Content = "x" }; + _blockService.GetHistoryVersionDiffAsync(5, 12, Arg.Any()).Returns(diff); + + var result = await _controller.GetHistoryVersionDiff(5, 12, TestContext.Current.CancellationToken); + + Assert.Same(diff, result.Value); + await _blockService.Received(1).GetHistoryVersionDiffAsync(5, 12, Arg.Any()); + } + + [Fact] + public async Task GetHistoryVersionDiff_ReturnsNotFound_WhenNull() + { + _blockService.GetHistoryVersionDiffAsync(5, 12, Arg.Any()).ReturnsNull(); + + var result = await _controller.GetHistoryVersionDiff(5, 12, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task DiffAgainstHistoryVersion_PassesPostedContentThrough_AndReturnsDto() + { + var diff = new ContentHistoryDiffDto { HasComparison = true, Content = "draft" }; + _blockService.DiffContentAgainstHistoryAsync(5, 12, "

draft

", Arg.Any()).Returns(diff); + var request = new DiffAgainstHistoryRequest { Content = "

draft

" }; + + var result = await _controller.DiffAgainstHistoryVersion(5, 12, request, TestContext.Current.CancellationToken); + + Assert.Same(diff, result.Value); + await _blockService.Received(1).DiffContentAgainstHistoryAsync(5, 12, "

draft

", + Arg.Any()); + } + + [Fact] + public async Task DiffAgainstHistoryVersion_ReturnsNotFound_WhenNull() + { + _blockService.DiffContentAgainstHistoryAsync(5, 12, Arg.Any(), Arg.Any()).ReturnsNull(); + + var result = await _controller.DiffAgainstHistoryVersion(5, 12, new DiffAgainstHistoryRequest { Content = "x" }, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region Restore / Delete + + [Fact] + public async Task RestoreContentBlock_ReturnsNoContent_WhenRestored() + { + _blockService.RestoreAsync(5, Arg.Any()).Returns(true); + + var result = await _controller.RestoreContentBlock(5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task RestoreContentBlock_ReturnsNotFound_WhenMissing() + { + _blockService.RestoreAsync(5, Arg.Any()).Returns(false); + + var result = await _controller.RestoreContentBlock(5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteContentBlock_SoftDelete_ReturnsNoContent() + { + _blockService.SoftDeleteAsync(5, Arg.Any()).Returns(true); + + var result = await _controller.DeleteContentBlock(5, permanent: false, TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _blockService.DidNotReceive().PermanentlyDeleteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteContentBlock_PermanentWithoutAdmin_ReturnsForbidden() + { + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false); + + var result = await _controller.DeleteContentBlock(5, permanent: true, TestContext.Current.CancellationToken); + + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode); + await _blockService.DidNotReceive().PermanentlyDeleteAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteContentBlock_PermanentAsAdmin_ReturnsNoContent() + { + var user = new AaudUser { AaudUserId = 1, LoginId = "admin" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true); + _blockService.PermanentlyDeleteAsync(5, Arg.Any()).Returns(true); + + var result = await _controller.DeleteContentBlock(5, permanent: true, TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _blockService.Received(1).PermanentlyDeleteAsync(5, Arg.Any()); + } + + #endregion +} diff --git a/test/CMS/CMSLeftNavControllerTests.cs b/test/CMS/CMSLeftNavControllerTests.cs new file mode 100644 index 000000000..5fa6cb2d4 --- /dev/null +++ b/test/CMS/CMSLeftNavControllerTests.cs @@ -0,0 +1,211 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NSubstitute.ReturnsExtensions; +using Viper.Areas.CMS.Controllers; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; + +namespace Viper.test.CMS; + +/// +/// Controller wiring tests for CMSLeftNavController: menu CRUD status mapping, the +/// duplicate-item-id guard on the batch item save, and cascade delete. The batch save/reorder +/// semantics themselves are covered in the CmsLeftNavService tests. +/// +public sealed class CMSLeftNavControllerTests +{ + private readonly ICmsLeftNavService _leftNavService; + private readonly CMSLeftNavController _controller; + + public CMSLeftNavControllerTests() + { + _leftNavService = Substitute.For(); + _controller = new CMSLeftNavController(_leftNavService); + + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = serviceProvider } + }; + } + + private static LeftNavMenuDto Menu(int id = 1) => new() { LeftNavMenuId = id, MenuHeaderText = "Cats", System = "Viper" }; + + #region Get + + [Fact] + public async Task GetMenus_PassesFiltersThrough() + { + var menus = new List { Menu() }; + _leftNavService.GetMenusAsync("Viper", "cats", "search", Arg.Any()).Returns(menus); + + var result = await _controller.GetMenus("Viper", "cats", "search", TestContext.Current.CancellationToken); + + Assert.Same(menus, result.Value); + await _leftNavService.Received(1).GetMenusAsync("Viper", "cats", "search", Arg.Any()); + } + + [Fact] + public async Task GetMenu_ReturnsMenu_WhenFound() + { + _leftNavService.GetMenuAsync(5, Arg.Any()).Returns(Menu(5)); + + var result = await _controller.GetMenu(5, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(5, result.Value!.LeftNavMenuId); + } + + [Fact] + public async Task GetMenu_ReturnsNotFound_WhenMissing() + { + _leftNavService.GetMenuAsync(999, Arg.Any()).ReturnsNull(); + + var result = await _controller.GetMenu(999, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region Create / Update + + [Fact] + public async Task CreateMenu_ReturnsCreatedAtAction() + { + var request = new LeftNavMenuAddEdit { MenuHeaderText = "Cats", System = "Viper" }; + _leftNavService.CreateMenuAsync(request, Arg.Any()).Returns(Menu(8)); + + var result = await _controller.CreateMenu(request, TestContext.Current.CancellationToken); + + var created = Assert.IsType(result.Result); + Assert.Equal(nameof(CMSLeftNavController.GetMenu), created.ActionName); + var dto = Assert.IsType(created.Value); + Assert.Equal(8, dto.LeftNavMenuId); + } + + [Fact] + public async Task UpdateMenu_ReturnsMenu_OnSuccess() + { + var request = new LeftNavMenuAddEdit { MenuHeaderText = "Cats", System = "Viper" }; + _leftNavService.UpdateMenuAsync(5, request, Arg.Any()).Returns(Menu(5)); + + var result = await _controller.UpdateMenu(5, request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(5, result.Value!.LeftNavMenuId); + } + + [Fact] + public async Task UpdateMenu_ReturnsNotFound_WhenMissing() + { + var request = new LeftNavMenuAddEdit { MenuHeaderText = "Cats", System = "Viper" }; + _leftNavService.UpdateMenuAsync(5, request, Arg.Any()).ReturnsNull(); + + var result = await _controller.UpdateMenu(5, request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region SaveItems + + private static LeftNavItemsSave ItemsSave(params LeftNavItemEdit[] items) => + new() { LastModifiedOn = DateTime.Now, Items = items.ToList() }; + + [Fact] + public async Task SaveItems_ReturnsBadRequest_OnArgumentException() + { + // The duplicate-id guard now lives in the service; the controller maps its ArgumentException to 400. + var request = ItemsSave(new LeftNavItemEdit { LeftNavItemId = 2, MenuItemText = "A" }); + _leftNavService.SaveItemsAsync(5, request, Arg.Any()) + .Throws(new ArgumentException("Duplicate item ids are not allowed.")); + + var result = await _controller.SaveItems(5, request, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result.Result); + Assert.Contains("Duplicate item ids", badRequest.Value?.ToString()); + } + + [Fact] + public async Task SaveItems_ReturnsConflict_OnStaleItemId() + { + var request = ItemsSave(new LeftNavItemEdit { LeftNavItemId = 99, MenuItemText = "Stale" }); + _leftNavService.SaveItemsAsync(5, request, Arg.Any()) + .Throws(new InvalidOperationException("One or more items no longer exist in this menu. Reload and try again.")); + + var result = await _controller.SaveItems(5, request, TestContext.Current.CancellationToken); + + var conflict = Assert.IsType(result.Result); + Assert.Contains("no longer exist", conflict.Value?.ToString()); + } + + [Fact] + public async Task SaveItems_ReturnsConflict_OnStaleStamp() + { + // CmsConcurrencyException derives from InvalidOperationException and must map to 409. + var request = ItemsSave(new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "A" }); + _leftNavService.SaveItemsAsync(5, request, Arg.Any()) + .Throws(new CmsConcurrencyException("This menu was modified by someone on 1/1/2026. Reload to get the latest version.")); + + var result = await _controller.SaveItems(5, request, TestContext.Current.CancellationToken); + + var conflict = Assert.IsType(result.Result); + Assert.Contains("Reload", conflict.Value?.ToString()); + } + + [Fact] + public async Task SaveItems_AllowsMultipleNewItems_WithZeroIds() + { + var request = ItemsSave( + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "New 1" }, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "New 2" }); + _leftNavService.SaveItemsAsync(5, request, Arg.Any()).Returns(Menu(5)); + + var result = await _controller.SaveItems(5, request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + await _leftNavService.Received(1).SaveItemsAsync(5, request, Arg.Any()); + } + + [Fact] + public async Task SaveItems_ReturnsNotFound_WhenMenuMissing() + { + var request = ItemsSave(new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "A" }); + _leftNavService.SaveItemsAsync(5, request, Arg.Any()).ReturnsNull(); + + var result = await _controller.SaveItems(5, request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region Delete + + [Fact] + public async Task DeleteMenu_ReturnsNoContent_WhenDeleted() + { + _leftNavService.DeleteMenuAsync(5, Arg.Any()).Returns(true); + + var result = await _controller.DeleteMenu(5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteMenu_ReturnsNotFound_WhenMissing() + { + _leftNavService.DeleteMenuAsync(5, Arg.Any()).Returns(false); + + var result = await _controller.DeleteMenu(5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + #endregion +} diff --git a/test/CMS/CMSLinkCollectionControllerTests.cs b/test/CMS/CMSLinkCollectionControllerTests.cs new file mode 100644 index 000000000..fcaee4af5 --- /dev/null +++ b/test/CMS/CMSLinkCollectionControllerTests.cs @@ -0,0 +1,383 @@ +using Areas.CMS.Models; +using Areas.CMS.Models.DTOs; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Controllers; +using Viper.Classes.SQLContext; + +namespace Viper.test.CMS; + +/// +/// Tests for CMSLinkCollectionController, which holds its EF logic directly (no service layer). +/// Covers collection CRUD with duplicate-name rejection and tag-category create/reorder/delete, +/// using a fresh EF in-memory VIPERContext per test. +/// +public sealed class CMSLinkCollectionControllerTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly CMSLinkCollectionController _controller; + + public CMSLinkCollectionControllerTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _controller = new CMSLinkCollectionController(_context); + } + + public void Dispose() => _context.Dispose(); + + private async Task SeedCollectionAsync(string name = "Resources") + { + var collection = new LinkCollection { LinkCollectionName = name }; + _context.LinkCollections.Add(collection); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return collection; + } + + private async Task SeedTagCategoryAsync(int collectionId, string name, int sortOrder) + { + var category = new LinkCollectionTagCategory + { + LinkCollectionId = collectionId, + LinkCollectionTagCategoryName = name, + SortOrder = sortOrder + }; + _context.LinkCollectionTagCategories.Add(category); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return category; + } + + #region Collections + + [Fact] + public async Task GetLinkCollections_ReturnsOrderedWithTagCategories() + { + var beta = await SeedCollectionAsync("Beta"); + await SeedCollectionAsync("Alpha"); + await SeedTagCategoryAsync(beta.LinkCollectionId, "Region", 2); + await SeedTagCategoryAsync(beta.LinkCollectionId, "Topic", 1); + + var result = await _controller.GetLinkCollections(); + + var ok = Assert.IsType(result.Result); + var collections = Assert.IsAssignableFrom>(ok.Value).ToList(); + // Collections are ordered by name at the top level. + Assert.Equal(new[] { "Alpha", "Beta" }, collections.Select(c => c.LinkCollection)); + var betaDto = collections.Single(c => c.LinkCollection == "Beta"); + // Both nested tag categories are projected with their SortOrder (the in-memory provider + // does not honor the filtered-include OrderBy, so assert membership, not sequence). + Assert.Equal(new[] { "Region", "Topic" }, + betaDto.LinkCollectionTagCategories.Select(tc => tc.LinkCollectionTagCategory).OrderBy(n => n)); + Assert.Equal(1, betaDto.LinkCollectionTagCategories.Single(tc => tc.LinkCollectionTagCategory == "Topic").SortOrder); + } + + [Fact] + public async Task PostLinkCollection_CreatesCollection() + { + var result = await _controller.PostLinkCollection(new CreateLinkCollectionDto { LinkCollection = "New" }); + + Assert.IsType(result.Result); + Assert.True(await _context.LinkCollections.AnyAsync(lc => lc.LinkCollectionName == "New", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PostLinkCollection_RejectsDuplicateName() + { + await SeedCollectionAsync(); + + var result = await _controller.PostLinkCollection(new CreateLinkCollectionDto { LinkCollection = "Resources" }); + + var badRequest = Assert.IsType(result.Result); + Assert.Contains("already exists", badRequest.Value?.ToString()); + Assert.Single(await _context.LinkCollections.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PutLinkCollection_RenamesCollection() + { + var collection = await SeedCollectionAsync("Old Name"); + + var result = await _controller.PutLinkCollection(collection.LinkCollectionId, + new CreateLinkCollectionDto { LinkCollection = "New Name" }); + + Assert.IsType(result); + var saved = await _context.LinkCollections.FindAsync( + new object[] { collection.LinkCollectionId }, TestContext.Current.CancellationToken); + Assert.Equal("New Name", saved!.LinkCollectionName); + } + + [Fact] + public async Task PutLinkCollection_ReturnsNotFound_WhenMissing() + { + var result = await _controller.PutLinkCollection(999, new CreateLinkCollectionDto { LinkCollection = "X" }); + + Assert.IsType(result); + } + + [Fact] + public async Task PutLinkCollection_RejectsRenameToExistingName() + { + await SeedCollectionAsync("Taken"); + var collection = await SeedCollectionAsync("Mine"); + + var result = await _controller.PutLinkCollection(collection.LinkCollectionId, + new CreateLinkCollectionDto { LinkCollection = "Taken" }); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteLinkCollection_RemovesCollection() + { + var collection = await SeedCollectionAsync(); + + var result = await _controller.DeleteLinkCollection(collection.LinkCollectionId); + + Assert.IsType(result); + Assert.Empty(await _context.LinkCollections.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteLinkCollection_RemovesLinksTagsAndCategories() + { + // A collection with a tag category, a link, and a link tag must delete cleanly. + // The DB FKs are not ON DELETE CASCADE, so the controller clears dependents first. + var collection = await SeedCollectionAsync(); + var category = await SeedTagCategoryAsync(collection.LinkCollectionId, "Topic", 1); + var link = new Link + { + LinkCollectionId = collection.LinkCollectionId, + Url = "https://example.com", + Title = "Example", + SortOrder = 1 + }; + _context.Links.Add(link); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + _context.LinkTags.Add(new LinkTag + { + LinkId = link.LinkId, + LinkCollectionTagCategoryId = category.LinkCollectionTagCategoryId, + SortOrder = 1, + Value = "alpha" + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.DeleteLinkCollection(collection.LinkCollectionId); + + Assert.IsType(result); + Assert.Empty(await _context.LinkCollections.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.Links.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LinkCollectionTagCategories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LinkTags.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteLinkCollection_ReturnsNotFound_WhenMissing() + { + var result = await _controller.DeleteLinkCollection(999); + + Assert.IsType(result); + } + + #endregion + + #region Tag categories + + [Fact] + public async Task GetLinkCollectionTagCategories_ReturnsOrdered() + { + var collection = await SeedCollectionAsync(); + await SeedTagCategoryAsync(collection.LinkCollectionId, "Second", 2); + await SeedTagCategoryAsync(collection.LinkCollectionId, "First", 1); + + var result = await _controller.GetLinkCollectionTagCategories(collection.LinkCollectionId); + + var ok = Assert.IsType(result.Result); + var categories = Assert.IsAssignableFrom>(ok.Value).ToList(); + Assert.Equal(new[] { "First", "Second" }, categories.Select(c => c.LinkCollectionTagCategory)); + } + + [Fact] + public async Task GetLinkCollectionTagCategories_ReturnsNotFound_WhenCollectionMissing() + { + var result = await _controller.GetLinkCollectionTagCategories(999); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task CreateLinkCollectionTagCategory_AddsCategory() + { + var collection = await SeedCollectionAsync(); + var dto = new CreateLinkCollectionTagCategoryDto + { + LinkCollectionId = collection.LinkCollectionId, + LinkCollectionTagCategory = "Topic", + SortOrder = 1 + }; + + var result = await _controller.CreateLinkCollectionTagCategory(collection.LinkCollectionId, dto); + + var created = Assert.IsType(result.Result); + var saved = await _context.LinkCollectionTagCategories.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("Topic", saved.LinkCollectionTagCategoryName); + Assert.Equal(collection.LinkCollectionId, saved.LinkCollectionId); + + // The response must carry the generated id so the client can reorder the + // new category (otherwise the follow-up tag-order PUT fails validation). + var returned = Assert.IsType(created.Value); + Assert.Equal(saved.LinkCollectionTagCategoryId, returned.LinkCollectionTagCategoryId); + Assert.NotEqual(0, returned.LinkCollectionTagCategoryId); + } + + [Fact] + public async Task CreateLinkCollectionTagCategory_ReturnsNotFound_WhenCollectionMissing() + { + var dto = new CreateLinkCollectionTagCategoryDto + { + LinkCollectionId = 999, + LinkCollectionTagCategory = "Topic", + SortOrder = 1 + }; + + var result = await _controller.CreateLinkCollectionTagCategory(999, dto); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task CreateLinkCollectionTagCategory_RejectsRouteBodyMismatch() + { + var collection = await SeedCollectionAsync(); + var dto = new CreateLinkCollectionTagCategoryDto + { + // Body claims a different collection than the route. + LinkCollectionId = collection.LinkCollectionId + 1, + LinkCollectionTagCategory = "Topic", + SortOrder = 1 + }; + + var result = await _controller.CreateLinkCollectionTagCategory(collection.LinkCollectionId, dto); + + Assert.IsType(result.Result); + Assert.Empty(await _context.LinkCollectionTagCategories.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CreateLinkCollectionTagCategory_RejectsDuplicateNameInCollection() + { + var collection = await SeedCollectionAsync(); + await SeedTagCategoryAsync(collection.LinkCollectionId, "Topic", 1); + var dto = new CreateLinkCollectionTagCategoryDto + { + LinkCollectionId = collection.LinkCollectionId, + LinkCollectionTagCategory = "Topic", + SortOrder = 2 + }; + + var result = await _controller.CreateLinkCollectionTagCategory(collection.LinkCollectionId, dto); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateLinkCollectionTagCategoryOrder_ReassignsSortOrder() + { + var collection = await SeedCollectionAsync(); + var first = await SeedTagCategoryAsync(collection.LinkCollectionId, "First", 1); + var second = await SeedTagCategoryAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkCollectionTagCategoryId = first.LinkCollectionTagCategoryId, SortOrder = 2 }, + new() { LinkCollectionTagCategoryId = second.LinkCollectionTagCategoryId, SortOrder = 1 } + }; + + var result = await _controller.UpdateLinkCollectionTagCategoryOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + Assert.Equal(2, (await _context.LinkCollectionTagCategories.FindAsync( + new object[] { first.LinkCollectionTagCategoryId }, TestContext.Current.CancellationToken))!.SortOrder); + Assert.Equal(1, (await _context.LinkCollectionTagCategories.FindAsync( + new object[] { second.LinkCollectionTagCategoryId }, TestContext.Current.CancellationToken))!.SortOrder); + } + + [Fact] + public async Task UpdateLinkCollectionTagCategoryOrder_RejectsDuplicateIds_WithMatchingCount() + { + // A duplicated id with a matching count would silently update one row twice and + // skip another; the guard must reject it. + var collection = await SeedCollectionAsync(); + var first = await SeedTagCategoryAsync(collection.LinkCollectionId, "First", 1); + await SeedTagCategoryAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkCollectionTagCategoryId = first.LinkCollectionTagCategoryId, SortOrder = 1 }, + new() { LinkCollectionTagCategoryId = first.LinkCollectionTagCategoryId, SortOrder = 2 } + }; + + var result = await _controller.UpdateLinkCollectionTagCategoryOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateLinkCollectionTagCategoryOrder_RejectsCountMismatch() + { + var collection = await SeedCollectionAsync(); + await SeedTagCategoryAsync(collection.LinkCollectionId, "First", 1); + await SeedTagCategoryAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkCollectionTagCategoryId = 1, SortOrder = 1 } + }; + + var result = await _controller.UpdateLinkCollectionTagCategoryOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateLinkCollectionTagCategoryOrder_RejectsUnknownCategoryId() + { + // Count matches, but one submitted id does not belong to the collection. + // The lookup must not throw (500); it returns a controlled BadRequest. + var collection = await SeedCollectionAsync(); + var first = await SeedTagCategoryAsync(collection.LinkCollectionId, "First", 1); + await SeedTagCategoryAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkCollectionTagCategoryId = first.LinkCollectionTagCategoryId, SortOrder = 1 }, + new() { LinkCollectionTagCategoryId = 999999, SortOrder = 2 } + }; + + var result = await _controller.UpdateLinkCollectionTagCategoryOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteLinkCollectionTagCategory_RemovesCategory() + { + var collection = await SeedCollectionAsync(); + var category = await SeedTagCategoryAsync(collection.LinkCollectionId, "Topic", 1); + + var result = await _controller.DeleteLinkCollectionTagCategory(collection.LinkCollectionId, + category.LinkCollectionTagCategoryId); + + Assert.IsType(result); + Assert.Empty(await _context.LinkCollectionTagCategories.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteLinkCollectionTagCategory_ReturnsNotFound_WhenCategoryMissing() + { + var collection = await SeedCollectionAsync(); + + var result = await _controller.DeleteLinkCollectionTagCategory(collection.LinkCollectionId, 999); + + Assert.IsType(result); + } + + #endregion +} diff --git a/test/CMS/CMSLinkCollectionLinksTests.cs b/test/CMS/CMSLinkCollectionLinksTests.cs new file mode 100644 index 000000000..97b1d39e4 --- /dev/null +++ b/test/CMS/CMSLinkCollectionLinksTests.cs @@ -0,0 +1,407 @@ +using Areas.CMS.Models; +using Areas.CMS.Models.DTOs; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Viper.Areas.CMS.Controllers; +using Viper.Classes.SQLContext; + +namespace Viper.test.CMS; + +/// +/// Tests for CMSLinkCollectionLinks, which holds its EF logic directly (no service layer). +/// Covers link CRUD, grouped-by-tag-category listing, link reordering, and the batch +/// link-tag save (remove-and-recreate). A fresh EF in-memory VIPERContext is used per test; +/// the in-memory provider's transaction warning is ignored so SaveLinkTags' transaction runs. +/// +public sealed class CMSLinkCollectionLinksTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly CMSLinkCollectionLinks _controller; + + public CMSLinkCollectionLinksTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options); + _controller = new CMSLinkCollectionLinks(_context); + } + + public void Dispose() => _context.Dispose(); + + private async Task SeedCollectionAsync(string name = "Resources") + { + var collection = new LinkCollection { LinkCollectionName = name }; + _context.LinkCollections.Add(collection); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return collection; + } + + private async Task SeedLinkAsync(int collectionId, string title, int sortOrder) + { + var link = new Link + { + LinkCollectionId = collectionId, + Url = "https://example.com/" + title, + Title = title, + SortOrder = sortOrder + }; + _context.Links.Add(link); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return link; + } + + private async Task SeedTagCategoryAsync(int collectionId, string name, int sortOrder) + { + var category = new LinkCollectionTagCategory + { + LinkCollectionId = collectionId, + LinkCollectionTagCategoryName = name, + SortOrder = sortOrder + }; + _context.LinkCollectionTagCategories.Add(category); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return category; + } + + private async Task SeedLinkTagAsync(int linkId, int categoryId, string value, int sortOrder) + { + _context.LinkTags.Add(new LinkTag + { + LinkId = linkId, + LinkCollectionTagCategoryId = categoryId, + Value = value, + SortOrder = sortOrder + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + #region GetLinks + + [Fact] + public async Task GetLinks_ReturnsLinksOrderedBySortOrder() + { + var collection = await SeedCollectionAsync(); + await SeedLinkAsync(collection.LinkCollectionId, "Second", 2); + await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + + var result = await _controller.GetLinks(collection.LinkCollectionId); + + var ok = Assert.IsType(result.Result); + var links = Assert.IsAssignableFrom>(ok.Value).ToList(); + Assert.Equal(new[] { "First", "Second" }, links.Select(l => l.Title)); + } + + [Fact] + public async Task GetLinks_ReturnsNotFound_WhenCollectionMissing() + { + var result = await _controller.GetLinks(999); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetLinks_GroupByTagCategory_GroupsLinksSharingATagValue() + { + var collection = await SeedCollectionAsync(); + var category = await SeedTagCategoryAsync(collection.LinkCollectionId, "Region", 1); + // Two links share the value "Coast"; one has "Inland". Grouping by the category emits + // each distinct value's links together, so the two "Coast" links must be adjacent. + var first = await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + var inland = await SeedLinkAsync(collection.LinkCollectionId, "Inland", 2); + var third = await SeedLinkAsync(collection.LinkCollectionId, "Third", 3); + await SeedLinkTagAsync(first.LinkId, category.LinkCollectionTagCategoryId, "Coast", 1); + await SeedLinkTagAsync(inland.LinkId, category.LinkCollectionTagCategoryId, "Inland", 1); + await SeedLinkTagAsync(third.LinkId, category.LinkCollectionTagCategoryId, "Coast", 1); + + var result = await _controller.GetLinks(collection.LinkCollectionId, groupByTagCategory: "region"); + + var ok = Assert.IsType(result.Result); + var titles = Assert.IsAssignableFrom>(ok.Value).Select(l => l.Title).ToList(); + Assert.Equal(3, titles.Count); + Assert.Equal(new[] { "First", "Inland", "Third" }, titles.OrderBy(t => t)); + // The two "Coast" links group together regardless of their SortOrder. + Assert.Equal(1, Math.Abs(titles.IndexOf("First") - titles.IndexOf("Third"))); + } + + [Fact] + public async Task GetLinks_GroupByUnknownTagCategory_ReturnsBadRequest() + { + var collection = await SeedCollectionAsync(); + await SeedLinkAsync(collection.LinkCollectionId, "A", 1); + + var result = await _controller.GetLinks(collection.LinkCollectionId, groupByTagCategory: "missing"); + + Assert.IsType(result.Result); + } + + #endregion + + #region Link CRUD + + [Fact] + public async Task PostLink_CreatesLink() + { + var collection = await SeedCollectionAsync(); + var dto = new CreateLinkDto + { + LinkCollectionId = collection.LinkCollectionId, + Url = "https://example.com", + Title = "Example", + SortOrder = 1 + }; + + var result = await _controller.PostLink(collection.LinkCollectionId, dto); + + Assert.IsType(result.Result); + Assert.Single(await _context.Links.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PostLink_ReturnsBadRequest_WhenCollectionIdMismatch() + { + var collection = await SeedCollectionAsync(); + var dto = new CreateLinkDto + { + LinkCollectionId = collection.LinkCollectionId + 1, + Url = "https://example.com", + Title = "Example", + SortOrder = 1 + }; + + var result = await _controller.PostLink(collection.LinkCollectionId, dto); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PutLink_UpdatesLink() + { + var collection = await SeedCollectionAsync(); + var link = await SeedLinkAsync(collection.LinkCollectionId, "Old", 1); + var dto = new CreateLinkDto + { + LinkCollectionId = collection.LinkCollectionId, + Url = "https://example.com/new", + Title = "New", + SortOrder = 5 + }; + + var result = await _controller.PutLink(link.LinkId, collection.LinkCollectionId, dto); + + Assert.IsType(result); + var saved = await _context.Links.FindAsync(new object[] { link.LinkId }, TestContext.Current.CancellationToken); + Assert.Equal("New", saved!.Title); + Assert.Equal(5, saved.SortOrder); + } + + [Fact] + public async Task PutLink_ReturnsNotFound_WhenLinkMissing() + { + var collection = await SeedCollectionAsync(); + var dto = new CreateLinkDto + { + LinkCollectionId = collection.LinkCollectionId, + Url = "https://example.com", + Title = "X", + SortOrder = 1 + }; + + var result = await _controller.PutLink(999, collection.LinkCollectionId, dto); + + Assert.IsType(result); + } + + [Fact] + public async Task PutLink_ReturnsBadRequest_WhenCollectionMismatch() + { + var collection = await SeedCollectionAsync(); + var link = await SeedLinkAsync(collection.LinkCollectionId, "Old", 1); + var dto = new CreateLinkDto + { + LinkCollectionId = collection.LinkCollectionId + 1, + Url = "https://example.com", + Title = "X", + SortOrder = 1 + }; + + var result = await _controller.PutLink(link.LinkId, collection.LinkCollectionId + 1, dto); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteLink_RemovesLinkAndTags() + { + var collection = await SeedCollectionAsync(); + var category = await SeedTagCategoryAsync(collection.LinkCollectionId, "Region", 1); + var link = await SeedLinkAsync(collection.LinkCollectionId, "ToDelete", 1); + await SeedLinkTagAsync(link.LinkId, category.LinkCollectionTagCategoryId, "West", 1); + + var result = await _controller.DeleteLink(collection.LinkCollectionId, link.LinkId); + + Assert.IsType(result); + Assert.Empty(await _context.Links.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LinkTags.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteLink_ReturnsBadRequest_WhenCollectionMismatch() + { + var collection = await SeedCollectionAsync(); + var link = await SeedLinkAsync(collection.LinkCollectionId, "X", 1); + + var result = await _controller.DeleteLink(collection.LinkCollectionId + 1, link.LinkId); + + Assert.IsType(result); + Assert.Single(await _context.Links.ToListAsync(TestContext.Current.CancellationToken)); + } + + #endregion + + #region Reorder + + [Fact] + public async Task UpdateLinkOrder_ReassignsSortOrder() + { + var collection = await SeedCollectionAsync(); + var first = await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + var second = await SeedLinkAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkId = first.LinkId, SortOrder = 2 }, + new() { LinkId = second.LinkId, SortOrder = 1 } + }; + + var result = await _controller.UpdateLinkOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + Assert.Equal(2, (await _context.Links.FindAsync( + new object[] { first.LinkId }, TestContext.Current.CancellationToken))!.SortOrder); + Assert.Equal(1, (await _context.Links.FindAsync( + new object[] { second.LinkId }, TestContext.Current.CancellationToken))!.SortOrder); + } + + [Fact] + public async Task UpdateLinkOrder_ReturnsNotFound_WhenCollectionMissing() + { + var result = await _controller.UpdateLinkOrder(999, new List()); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateLinkOrder_RejectsCountMismatch() + { + var collection = await SeedCollectionAsync(); + await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + await SeedLinkAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List { new() { LinkId = 1, SortOrder = 1 } }; + + var result = await _controller.UpdateLinkOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateLinkOrder_RejectsUnknownLinkId_WithMatchingCount() + { + var collection = await SeedCollectionAsync(); + var first = await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + await SeedLinkAsync(collection.LinkCollectionId, "Second", 2); + // Matches the link count, but one id belongs to no link in the collection: the guard must + // reject on membership, not just count (an unknown id used to crash the First() lookup). + var updates = new List + { + new() { LinkId = first.LinkId, SortOrder = 2 }, + new() { LinkId = 999999, SortOrder = 1 } + }; + + var result = await _controller.UpdateLinkOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateLinkOrder_RejectsDuplicateIds_WithMatchingCount() + { + // Matches the link count, but the same id appears twice: the guard must reject on + // distinctness, not just count (mirrors the tag-category reorder guard's coverage). + var collection = await SeedCollectionAsync(); + var first = await SeedLinkAsync(collection.LinkCollectionId, "First", 1); + await SeedLinkAsync(collection.LinkCollectionId, "Second", 2); + var updates = new List + { + new() { LinkId = first.LinkId, SortOrder = 1 }, + new() { LinkId = first.LinkId, SortOrder = 2 } + }; + + var result = await _controller.UpdateLinkOrder(collection.LinkCollectionId, updates); + + Assert.IsType(result); + } + + #endregion + + #region SaveLinkTags + + [Fact] + public async Task SaveLinkTags_ReplacesTagsFromCommaSeparatedValues() + { + var collection = await SeedCollectionAsync(); + var category = await SeedTagCategoryAsync(collection.LinkCollectionId, "Region", 1); + var link = await SeedLinkAsync(collection.LinkCollectionId, "Link", 1); + await SeedLinkTagAsync(link.LinkId, category.LinkCollectionTagCategoryId, "Stale", 1); + var tagValues = new Dictionary + { + { category.LinkCollectionTagCategoryId, "West,East" } + }; + + var result = await _controller.SaveLinkTags(link.LinkId, collection.LinkCollectionId, tagValues); + + Assert.IsType(result); + var tags = await _context.LinkTags.Where(lt => lt.LinkId == link.LinkId) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, tags.Count); + Assert.DoesNotContain(tags, t => t.Value == "Stale"); + Assert.Contains(tags, t => t.Value == "West"); + Assert.Contains(tags, t => t.Value == "East"); + } + + [Fact] + public async Task SaveLinkTags_ReturnsNotFound_WhenLinkMissing() + { + var collection = await SeedCollectionAsync(); + + var result = await _controller.SaveLinkTags(999, collection.LinkCollectionId, new Dictionary()); + + Assert.IsType(result); + } + + [Fact] + public async Task SaveLinkTags_ReturnsBadRequest_WhenCollectionMismatch() + { + var collection = await SeedCollectionAsync(); + var link = await SeedLinkAsync(collection.LinkCollectionId, "Link", 1); + + var result = await _controller.SaveLinkTags(link.LinkId, collection.LinkCollectionId + 1, + new Dictionary()); + + Assert.IsType(result); + } + + [Fact] + public async Task SaveLinkTags_ReturnsBadRequest_WhenTagCategoryNotInCollection() + { + var collection = await SeedCollectionAsync(); + var link = await SeedLinkAsync(collection.LinkCollectionId, "Link", 1); + var tagValues = new Dictionary { { 9999, "West" } }; + + var result = await _controller.SaveLinkTags(link.LinkId, collection.LinkCollectionId, tagValues); + + Assert.IsType(result); + } + + #endregion +} diff --git a/test/CMS/CmsContentBlockServiceTests.cs b/test/CMS/CmsContentBlockServiceTests.cs new file mode 100644 index 000000000..e9e2c0607 --- /dev/null +++ b/test/CMS/CmsContentBlockServiceTests.cs @@ -0,0 +1,556 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; +using Viper.Services; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsContentBlockService: filtering, create/update with permission and file deltas, +/// history semantics (previous version is stored, stamped with its original author/time), +/// concurrency conflicts, soft delete/restore, and permanent delete cascade. +/// +public sealed class CmsContentBlockServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly IHtmlSanitizerService _sanitizer; + private readonly CmsContentBlockService _service; + + public CmsContentBlockServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _sanitizer = Substitute.For(); + _sanitizer.Sanitize(Arg.Any()).Returns(callInfo => callInfo.ArgAt(0)); + // Pass-through so diff tests assert on the real htmldiff.net markers, not a sanitized copy. + _sanitizer.SanitizeDiff(Arg.Any()).Returns(callInfo => callInfo.ArgAt(0)); + + _service = new CmsContentBlockService(_context, _sanitizer, Substitute.For()); + } + + public void Dispose() + { + _context.Dispose(); + } + + private async Task SeedBlockAsync(Action? customize = null) + { + var block = new ContentBlock + { + Content = "

original

", + Title = "Seeded Block", + System = "Viper", + ViperSectionPath = "cats", + Page = "home", + BlockOrder = 1, + FriendlyName = "seeded-block-" + Guid.NewGuid().ToString("N")[..8], + ModifiedOn = DateTime.Now.AddDays(-2), + ModifiedBy = "originalAuthor" + }; + customize?.Invoke(block); + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return block; + } + + private static CMSBlockAddEdit MakeRequest(ContentBlock block, Action? customize = null) + { + var request = new CMSBlockAddEdit + { + ContentBlockId = block.ContentBlockId, + Content = block.Content, + Title = block.Title, + System = block.System, + Page = block.Page, + ViperSectionPath = block.ViperSectionPath, + BlockOrder = block.BlockOrder, + FriendlyName = block.FriendlyName, + AllowPublicAccess = block.AllowPublicAccess, + LastModifiedOn = block.ModifiedOn + }; + customize?.Invoke(request); + return request; + } + + #region List / Get + + [Fact] + public async Task GetContentBlocks_FiltersByStatus() + { + await SeedBlockAsync(); + await SeedBlockAsync(b => b.DeletedOn = DateTime.Now); + + var active = await _service.GetContentBlocksAsync("active", null, null, null, null, 1, 50, "title", false, TestContext.Current.CancellationToken); + var deleted = await _service.GetContentBlocksAsync("deleted", null, null, null, null, 1, 50, "title", false, TestContext.Current.CancellationToken); + var all = await _service.GetContentBlocksAsync("all", null, null, null, null, 1, 50, "title", false, TestContext.Current.CancellationToken); + + Assert.Single(active.Blocks); + Assert.Single(deleted.Blocks); + Assert.Equal(2, all.Blocks.Count); + Assert.Equal(2, all.Total); + } + + [Fact] + public async Task GetContentBlocks_PagesSortsAndReturnsTotal() + { + await SeedBlockAsync(b => b.Title = "Charlie"); + await SeedBlockAsync(b => b.Title = "Alpha"); + await SeedBlockAsync(b => b.Title = "Bravo"); + + var page1 = await _service.GetContentBlocksAsync("active", null, null, null, null, 1, 2, "title", false, + TestContext.Current.CancellationToken); + var page2 = await _service.GetContentBlocksAsync("active", null, null, null, null, 2, 2, "title", false, + TestContext.Current.CancellationToken); + + // Total counts all matches; the page returns only its slice, sorted by title across pages. + Assert.Equal(3, page1.Total); + Assert.Equal(new[] { "Alpha", "Bravo" }, page1.Blocks.Select(b => b.Title)); + Assert.Single(page2.Blocks); + Assert.Equal("Charlie", page2.Blocks[0].Title); + + var desc = await _service.GetContentBlocksAsync("active", null, null, null, null, 1, 1, "title", true, + TestContext.Current.CancellationToken); + Assert.Equal("Charlie", desc.Blocks[0].Title); + } + + [Fact] + public async Task GetContentBlocks_PageZero_ClampsToFirstPage() + { + // ApiPagination admits page=0; Skip(-perPage) would throw, so the service clamps. + await SeedBlockAsync(b => b.Title = "Alpha"); + + var result = await _service.GetContentBlocksAsync("active", null, null, null, null, 0, 0, "title", false, + TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Total); + Assert.Single(result.Blocks); + } + + [Fact] + public async Task GetContentBlocks_ListOmitsContent() + { + await SeedBlockAsync(); + + var list = await _service.GetContentBlocksAsync("active", null, null, null, null, 1, 50, "title", false, TestContext.Current.CancellationToken); + + Assert.Equal(string.Empty, list.Blocks[0].Content); + } + + [Fact] + public async Task GetContentBlock_ReturnsContentAndRelations() + { + var block = await SeedBlockAsync(b => + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure.CATS" })); + + var dto = await _service.GetContentBlockAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + + Assert.NotNull(dto); + Assert.Equal("

original

", dto.Content); + Assert.Equal(new List { "SVMSecure.CATS" }, dto.Permissions); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_SavesBlockWithPermissionsAndNoHistory() + { + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "

new

", + Title = "New Block", + System = "Viper", + AllowPublicAccess = false, + Permissions = new List { "SVMSecure.CATS" } + }; + + var dto = await _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken); + + Assert.True(dto.ContentBlockId > 0); + Assert.Equal(new List { "SVMSecure.CATS" }, dto.Permissions); + // Legacy semantics: history holds previous versions only, so a new block has none. + Assert.Empty(_context.ContentHistories.Where(h => h.ContentBlockId == dto.ContentBlockId)); + } + + [Fact] + public async Task Create_DuplicateFriendlyName_Throws() + { + var existing = await SeedBlockAsync(); + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "x", + Title = "Dup", + System = "Viper", + AllowPublicAccess = false, + FriendlyName = existing.FriendlyName + }; + + await Assert.ThrowsAsync( + () => _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Create_UnknownFileGuid_Throws() + { + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "x", + Title = "Has Bad File", + System = "Viper", + AllowPublicAccess = false, + FileGuids = new List { Guid.NewGuid() } + }; + + await Assert.ThrowsAsync( + () => _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken)); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_WritesPreviousVersionToHistory() + { + var block = await SeedBlockAsync(); + var originalModifiedOn = block.ModifiedOn; + var request = MakeRequest(block, r => r.Content = "

updated

"); + + var dto = await _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken); + + Assert.Equal("

updated

", dto!.Content); + var history = Assert.Single(_context.ContentHistories.Where(h => h.ContentBlockId == block.ContentBlockId)); + Assert.Equal("

original

", history.ContentBlockContent); + Assert.Equal("originalAuthor", history.ModifiedBy); + Assert.Equal(originalModifiedOn, history.ModifiedOn); + } + + [Fact] + public async Task Update_StaleLastModifiedOn_ThrowsConcurrency() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.LastModifiedOn = block.ModifiedOn.AddMinutes(-5)); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_NullLastModifiedOn_Throws() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.LastModifiedOn = null); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_UnknownFileGuid_Throws() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.FileGuids = new List { Guid.NewGuid() }); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_AppliesPermissionAndFileDeltas() + { + var file = new Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\attach.pdf", + Folder = "cats", + FriendlyName = "cats-attach.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + _context.Files.Add(file); + var block = await SeedBlockAsync(b => + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure.Old" })); + + var request = MakeRequest(block, r => + { + r.Permissions = new List { "SVMSecure.New" }; + r.FileGuids = new List { file.FileGuid }; + }); + + var dto = await _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken); + + Assert.Equal(new List { "SVMSecure.New" }, dto!.Permissions); + Assert.Single(dto.Files); + Assert.Equal("cats-attach.pdf", dto.Files[0].FriendlyName); + } + + [Fact] + public async Task UpdateContentOnly_PreservesOtherFieldsAndWritesHistory() + { + var block = await SeedBlockAsync(); + + var dto = await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

quick edit

", block.ModifiedOn, + TestContext.Current.CancellationToken); + + Assert.Equal("

quick edit

", dto!.Content); + Assert.Equal("Seeded Block", dto.Title); + var history = Assert.Single(_context.ContentHistories.Where(h => h.ContentBlockId == block.ContentBlockId)); + Assert.Equal("

original

", history.ContentBlockContent); + } + + #endregion + + #region Delete / Restore / History + + [Fact] + public async Task SoftDeleteAndRestore_ToggleDeletedOn() + { + var block = await SeedBlockAsync(); + + Assert.True(await _service.SoftDeleteAsync(block.ContentBlockId, TestContext.Current.CancellationToken)); + Assert.NotNull((await _context.ContentBlocks.SingleAsync(TestContext.Current.CancellationToken)).DeletedOn); + + Assert.True(await _service.RestoreAsync(block.ContentBlockId, TestContext.Current.CancellationToken)); + Assert.Null((await _context.ContentBlocks.SingleAsync(TestContext.Current.CancellationToken)).DeletedOn); + } + + [Fact] + public async Task PermanentDelete_RemovesBlockAndChildren() + { + var block = await SeedBlockAsync(b => + { + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure" }); + b.ContentHistories.Add(new ContentHistory + { + ContentBlockContent = "old", + ModifiedOn = DateTime.Now.AddDays(-3), + ModifiedBy = "x" + }); + }); + + var result = await _service.PermanentlyDeleteAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.Empty(await _context.ContentBlocks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.ContentHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.ContentBlockToPermissions.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task History_ListAndVersionRetrieval() + { + var block = await SeedBlockAsync(); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v3

", block.ModifiedOn, TestContext.Current.CancellationToken); + + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + Assert.Equal(2, history.Count); + + var oldest = await _service.GetHistoryVersionAsync(block.ContentBlockId, history[^1].ContentHistoryId, + TestContext.Current.CancellationToken); + Assert.Equal("

original

", oldest!.Content); + } + + #endregion + + #region Cross-block edit history + + private async Task SeedBlockWithHistoryAsync(string title, string page, bool deleted, + params (string author, DateTime when)[] versions) + { + var block = await SeedBlockAsync(b => + { + b.Title = title; + b.Page = page; + b.DeletedOn = deleted ? DateTime.Now : null; + foreach (var (author, when) in versions) + { + b.ContentHistories.Add(new ContentHistory + { + ContentBlockContent = $"

{author}@{when:o}

", + ModifiedOn = when, + ModifiedBy = author + }); + } + }); + return block; + } + + [Fact] + public async Task GetHistoryEntries_ReturnsAcrossBlocks_NewestFirst_WithBlockInfo() + { + var now = DateTime.Now; + await SeedBlockWithHistoryAsync("Alpha", "home", deleted: false, + ("editorX", now.AddDays(-3)), ("editorY", now.AddDays(-1))); + await SeedBlockWithHistoryAsync("Beta", "about", deleted: true, + ("editorX", now.AddDays(-2))); + + var entries = await _service.GetHistoryEntriesAsync(new CmsContentHistoryFilter(), 1, 50, + TestContext.Current.CancellationToken); + + Assert.Equal(3, entries.Count); + // Newest first: editorY@-1 (Alpha), editorX@-2 (Beta), editorX@-3 (Alpha). + Assert.Equal("editorY", entries[0].ModifiedBy); + Assert.Equal("Alpha", entries[0].Title); + Assert.Equal("home", entries[0].Page); + Assert.False(entries[0].BlockDeleted); + Assert.Equal("Beta", entries[1].Title); + Assert.True(entries[1].BlockDeleted); + } + + [Fact] + public async Task GetHistoryEntries_FiltersByEditorBlockSearchAndDate() + { + var now = DateTime.Now; + var alpha = await SeedBlockWithHistoryAsync("Alpha", "home", deleted: false, + ("editorX", now.AddDays(-3)), ("editorY", now.AddDays(-1))); + await SeedBlockWithHistoryAsync("Beta", "about", deleted: false, + ("editorX", now.AddDays(-2))); + + var byEditor = await _service.GetHistoryEntriesAsync(new CmsContentHistoryFilter { ModifiedBy = "editorX" }, + 1, 50, TestContext.Current.CancellationToken); + Assert.Equal(2, byEditor.Count); + Assert.All(byEditor, e => Assert.Equal("editorX", e.ModifiedBy)); + + var byBlock = await _service.GetHistoryEntriesAsync(new CmsContentHistoryFilter { ContentBlockId = alpha.ContentBlockId }, + 1, 50, TestContext.Current.CancellationToken); + Assert.Equal(2, byBlock.Count); + Assert.All(byBlock, e => Assert.Equal(alpha.ContentBlockId, e.ContentBlockId)); + + var bySearch = await _service.GetHistoryEntriesAsync(new CmsContentHistoryFilter { Search = "Beta" }, + 1, 50, TestContext.Current.CancellationToken); + Assert.Single(bySearch); + Assert.Equal("Beta", bySearch[0].Title); + + // To is inclusive through end of the given day; From excludes older rows. + var byDate = await _service.GetHistoryEntriesAsync( + new CmsContentHistoryFilter { From = now.AddDays(-2).Date, To = now.Date }, + 1, 50, TestContext.Current.CancellationToken); + Assert.DoesNotContain(byDate, e => e.ModifiedOn < now.AddDays(-2).Date); + } + + [Fact] + public async Task GetHistoryEntries_PaginatesAndCounts() + { + var now = DateTime.Now; + await SeedBlockWithHistoryAsync("Alpha", "home", deleted: false, + ("a", now.AddDays(-3)), ("b", now.AddDays(-2)), ("c", now.AddDays(-1))); + + var filter = new CmsContentHistoryFilter(); + var firstPage = await _service.GetHistoryEntriesAsync(filter, 1, 2, TestContext.Current.CancellationToken); + var secondPage = await _service.GetHistoryEntriesAsync(filter, 2, 2, TestContext.Current.CancellationToken); + var total = await _service.GetHistoryEntryCountAsync(filter, TestContext.Current.CancellationToken); + + Assert.Equal(2, firstPage.Count); + Assert.Single(secondPage); + Assert.Equal(3, total); + } + + #endregion + + #region Version diff + + [Fact] + public async Task GetHistoryVersionDiff_AgainstPreviousVersion_MarksChangesAndReSanitizes() + { + var block = await SeedBlockAsync(); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v3

", block.ModifiedOn, TestContext.Current.CancellationToken); + + // History newest-first is [v2, original]; v2 has a predecessor (original) to diff against. + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + var v2 = history[0]; + + var diff = await _service.GetHistoryVersionDiffAsync(block.ContentBlockId, v2.ContentHistoryId, + TestContext.Current.CancellationToken); + + Assert.NotNull(diff); + Assert.True(diff.HasComparison); + Assert.True(diff.HasChanges); + Assert.Equal("originalAuthor", diff.OldModifiedBy); + Assert.Contains("()); + } + + [Fact] + public async Task GetHistoryVersionDiff_OriginalVersion_HasNoComparison() + { + var block = await SeedBlockAsync(); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + var original = history[^1]; + + var diff = await _service.GetHistoryVersionDiffAsync(block.ContentBlockId, original.ContentHistoryId, + TestContext.Current.CancellationToken); + + Assert.NotNull(diff); + Assert.False(diff.HasComparison); + Assert.Equal("

original

", diff.Content); + Assert.DoesNotContain("v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + var original = history[^1]; + + var diff = await _service.DiffContentAgainstHistoryAsync(block.ContentBlockId, original.ContentHistoryId, + "

current draft

", TestContext.Current.CancellationToken); + + Assert.NotNull(diff); + Assert.True(diff.HasComparison); + Assert.True(diff.HasChanges); + Assert.Equal("originalAuthor", diff.OldModifiedBy); + Assert.Contains("v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + var original = history[^1]; // holds "

original

" + + // Current draft is byte-identical to the selected version: htmldiff emits no ins/del. + var diff = await _service.DiffContentAgainstHistoryAsync(block.ContentBlockId, original.ContentHistoryId, + "

original

", TestContext.Current.CancellationToken); + + Assert.NotNull(diff); + Assert.True(diff.HasComparison); + Assert.False(diff.HasChanges); + Assert.DoesNotContain(" +/// Tests for Data.LeftNavMenu.GetLeftNavMenus display-time permission filtering (legacy CMS +/// left-nav parity): an item with no permissions is visible to any logged-in user, an item is +/// shown when the user holds any one of its permissions and hidden otherwise, and filtering is +/// bypassed for management callers. Permission scenarios are driven by mocking IUserHelper. +/// +public sealed class CmsLeftNavDisplayTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + + public CmsLeftNavDisplayTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _rapsContext = new RAPSContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options); + _userHelper = Substitute.For(); + } + + public void Dispose() + { + _context.Dispose(); + _rapsContext.Dispose(); + } + + private async Task SeedMixedMenuAsync() + { + var friendlyName = "display-menu-" + Guid.NewGuid().ToString("N")[..8]; + var menu = new LeftNavMenu + { + MenuHeaderText = "Display Menu", + System = "Viper", + FriendlyName = friendlyName, + ModifiedOn = DateTime.Now, + ModifiedBy = "test" + }; + menu.LeftNavItems.Add(Item("Public", 1)); // no permissions + menu.LeftNavItems.Add(Item("Allowed", 2, "SVMSecure.Allowed")); // user holds this + menu.LeftNavItems.Add(Item("Forbidden", 3, "SVMSecure.Forbidden")); // user lacks this + _context.LeftNavMenus.Add(menu); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return friendlyName; + } + + private static LeftNavItem Item(string text, int order, params string[] permissions) + { + var item = new LeftNavItem { MenuItemText = text, IsHeader = false, Url = "/" + text, DisplayOrder = order }; + foreach (var p in permissions) + { + item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = p }); + } + return item; + } + + [Fact] + public async Task GetLeftNavMenus_HidesItemsTheUserLacksPermissionFor() + { + var friendlyName = await SeedMixedMenuAsync(); + var user = new AaudUser { AaudUserId = 1, LoginId = "viewer" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(Arg.Any(), Arg.Any(), "SVMSecure.Allowed").Returns(true); + // HasPermission for "SVMSecure.Forbidden" defaults to false. + + var menus = new DataLeftNav(_context, _rapsContext, _userHelper) + .GetLeftNavMenus(friendlyName: friendlyName); + + var items = Assert.Single(menus!).MenuItems!; + // No-permission item and the held-permission item are visible; the unheld one is hidden. + // (Assert membership, not sequence — the in-memory provider does not honor the include OrderBy.) + Assert.Equal(new[] { "Allowed", "Public" }, items.Select(i => i.MenuItemText).OrderBy(t => t)); + } + + [Fact] + public async Task GetLeftNavMenus_AnonymousUser_HidesPermissionLessItems() + { + var friendlyName = await SeedMixedMenuAsync(); + // An anonymous request has no signed-in user; GetCurrentUser returns null. + _userHelper.GetCurrentUser().Returns((AaudUser?)null); + + var menus = new DataLeftNav(_context, _rapsContext, _userHelper) + .GetLeftNavMenus(friendlyName: friendlyName); + + var items = Assert.Single(menus!).MenuItems!; + // Permission-less items are visible only to logged-in users, so an anonymous request sees + // none of them; holding no permissions, it sees the permissioned items too - nothing shows. + Assert.DoesNotContain("Public", items.Select(i => i.MenuItemText)); + Assert.Empty(items); + } + + [Fact] + public async Task GetLeftNavMenus_ReturnsAllItems_WhenFilteringDisabled() + { + var friendlyName = await SeedMixedMenuAsync(); + _userHelper.GetCurrentUser().Returns(new AaudUser { AaudUserId = 1, LoginId = "viewer" }); + // HasPermission would return false for every permission, but management callers bypass filtering. + + var menus = new DataLeftNav(_context, _rapsContext, _userHelper) + .GetLeftNavMenus(friendlyName: friendlyName, filterItemsByPermissions: false); + + var items = Assert.Single(menus!).MenuItems!; + Assert.Equal(new[] { "Allowed", "Forbidden", "Public" }, items.Select(i => i.MenuItemText).OrderBy(t => t)); + } +} diff --git a/test/CMS/CmsLeftNavServiceTests.cs b/test/CMS/CmsLeftNavServiceTests.cs new file mode 100644 index 000000000..3c43d2361 --- /dev/null +++ b/test/CMS/CmsLeftNavServiceTests.cs @@ -0,0 +1,347 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsLeftNavService: menu CRUD with cascade delete and the batch item save +/// (add/update/delete/reorder in one call, replacing the legacy DataTables editor). +/// +public sealed class CmsLeftNavServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly CmsLeftNavService _service; + + public CmsLeftNavServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _service = new CmsLeftNavService(_context, Substitute.For()); + } + + public void Dispose() + { + _context.Dispose(); + } + + private async Task SeedMenuAsync(Action? customize = null) + { + var menu = new LeftNavMenu + { + MenuHeaderText = "Seeded Menu", + System = "Viper", + ViperSectionPath = "cats", + FriendlyName = "seeded-menu-" + Guid.NewGuid().ToString("N")[..8], + ModifiedOn = DateTime.Now.AddDays(-1), + ModifiedBy = "test" + }; + customize?.Invoke(menu); + _context.LeftNavMenus.Add(menu); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return menu; + } + + private static LeftNavItem MakeItem(string text, int order, bool isHeader = false, string? url = null, params string[] permissions) + { + var item = new LeftNavItem + { + MenuItemText = text, + IsHeader = isHeader, + Url = url, + DisplayOrder = order + }; + foreach (var p in permissions) + { + item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = p }); + } + return item; + } + + [Fact] + public async Task GetMenus_FiltersBySystemAndSearch() + { + await SeedMenuAsync(); + await SeedMenuAsync(m => + { + m.MenuHeaderText = "Public Menu"; + m.System = "Public"; + }); + + var viperMenus = await _service.GetMenusAsync("Viper", null, null, TestContext.Current.CancellationToken); + var searched = await _service.GetMenusAsync(null, null, "Public", TestContext.Current.CancellationToken); + + Assert.Single(viperMenus); + Assert.Single(searched); + Assert.Equal("Public Menu", searched[0].MenuHeaderText); + } + + [Fact] + public async Task GetMenu_ReturnsItemsInDisplayOrder() + { + var menu = await SeedMenuAsync(m => + { + m.LeftNavItems.Add(MakeItem("Second", 2, url: "/two")); + m.LeftNavItems.Add(MakeItem("First", 1, isHeader: true)); + }); + + var dto = await _service.GetMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "First", "Second" }, dto!.Items.Select(i => i.MenuItemText).ToArray()); + Assert.True(dto.Items[0].IsHeader); + } + + [Fact] + public async Task CreateAndUpdateMenu_PersistFields() + { + var created = await _service.CreateMenuAsync(new LeftNavMenuAddEdit + { + MenuHeaderText = "New Menu", + System = "Viper", + ViperSectionPath = "students", + Page = "home", + FriendlyName = "new-menu" + }, TestContext.Current.CancellationToken); + + Assert.True(created.LeftNavMenuId > 0); + + var updated = await _service.UpdateMenuAsync(created.LeftNavMenuId, new LeftNavMenuAddEdit + { + MenuHeaderText = "Renamed Menu", + System = "Viper", + LastModifiedOn = created.ModifiedOn + }, TestContext.Current.CancellationToken); + + Assert.Equal("Renamed Menu", updated!.MenuHeaderText); + Assert.Null(updated.ViperSectionPath); + } + + [Fact] + public async Task CreateMenu_DuplicateFriendlyName_ThrowsCaseInsensitive() + { + await SeedMenuAsync(m => m.FriendlyName = "cats-home"); + + await Assert.ThrowsAsync(() => _service.CreateMenuAsync(new LeftNavMenuAddEdit + { + MenuHeaderText = "Dup", + System = "Viper", + FriendlyName = "CATS-HOME" + }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateMenu_DuplicateFriendlyName_Rejected_ButKeepingOwnIsAllowed() + { + var alpha = await SeedMenuAsync(m => m.FriendlyName = "alpha"); + var beta = await SeedMenuAsync(m => m.FriendlyName = "beta"); + + // Renaming beta onto alpha's friendly name (any case) is rejected. + await Assert.ThrowsAsync(() => _service.UpdateMenuAsync(beta.LeftNavMenuId, + new LeftNavMenuAddEdit { MenuHeaderText = "B", System = "Viper", FriendlyName = "ALPHA", LastModifiedOn = beta.ModifiedOn }, + TestContext.Current.CancellationToken)); + + // Keeping its own friendly name (self-match excluded) is allowed. + var updated = await _service.UpdateMenuAsync(alpha.LeftNavMenuId, + new LeftNavMenuAddEdit { MenuHeaderText = "A renamed", System = "Viper", FriendlyName = "ALPHA", LastModifiedOn = alpha.ModifiedOn }, + TestContext.Current.CancellationToken); + + Assert.NotNull(updated); + } + + [Fact] + public async Task UpdateMenu_StaleLastModifiedOn_ThrowsConcurrency() + { + var menu = await SeedMenuAsync(); + + var ex = await Assert.ThrowsAsync(() => _service.UpdateMenuAsync( + menu.LeftNavMenuId, + new LeftNavMenuAddEdit + { + MenuHeaderText = "Renamed", + System = "Viper", + LastModifiedOn = menu.ModifiedOn.AddMinutes(-5) + }, + TestContext.Current.CancellationToken)); + + // Names who saved and when so the user knows whose edit they'd clobber. + Assert.Contains("modified by test", ex.Message); + } + + [Fact] + public async Task UpdateMenu_MissingLastModifiedOn_ThrowsArgumentException() + { + var menu = await SeedMenuAsync(); + + await Assert.ThrowsAsync(() => _service.UpdateMenuAsync( + menu.LeftNavMenuId, + new LeftNavMenuAddEdit { MenuHeaderText = "Renamed", System = "Viper" }, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteMenu_CascadesToItemsAndPermissions() + { + var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Link", 1, url: "/x", permissions: "SVMSecure.CATS"))); + + var result = await _service.DeleteMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.Empty(await _context.LeftNavMenus.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LeftNavItems.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LeftNavItemToPermissions.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_AddsUpdatesDeletesAndReorders() + { + var menu = await SeedMenuAsync(m => + { + m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep", permissions: "SVMSecure.Old")); + m.LeftNavItems.Add(MakeItem("Delete Me", 2, url: "/delete")); + }); + var keepId = menu.LeftNavItems.First(i => i.MenuItemText == "Keep Me").LeftNavItemId; + + var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "New Header", IsHeader = true }, + new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "Keep Me Renamed", Url = "/kept", Permissions = new List { "SVMSecure.New" } }), + TestContext.Current.CancellationToken); + + Assert.Equal(2, dto!.Items.Count); + Assert.Equal("New Header", dto.Items[0].MenuItemText); + Assert.Equal(1, dto.Items[0].DisplayOrder); + Assert.True(dto.Items[0].IsHeader); + Assert.Equal("Keep Me Renamed", dto.Items[1].MenuItemText); + Assert.Equal(2, dto.Items[1].DisplayOrder); + Assert.Equal(new List { "SVMSecure.New" }, dto.Items[1].Permissions); + // Deleted item's permissions are gone too + Assert.Equal(1, await _context.LeftNavItemToPermissions.CountAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_HeaderItems_DropUrl() + { + var menu = await SeedMenuAsync(); + + var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true, Url = "/should-be-dropped" }), + TestContext.Current.CancellationToken); + + Assert.Null(dto!.Items[0].Url); + } + + [Fact] + public async Task SaveItems_EmptyHeaderText_IsAllowed() + { + // Legacy parity: menus use empty-text headers as spacer rows, and the display side + // renders them as blank dividers. A menu containing one must stay saveable. + var menu = await SeedMenuAsync(); + + var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "", IsHeader = true }, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = "A Link", IsHeader = false, Url = "/link" }), + TestContext.Current.CancellationToken); + + Assert.Equal(2, dto!.Items.Count); + Assert.Equal("", dto.Items[0].MenuItemText); + Assert.True(dto.Items[0].IsHeader); + } + + [Fact] + public async Task SaveItems_EmptyLinkText_Throws() + { + var menu = await SeedMenuAsync(); + + await Assert.ThrowsAsync(() => + _service.SaveItemsAsync(menu.LeftNavMenuId, ItemsSave(menu, + new LeftNavItemEdit { LeftNavItemId = 0, MenuItemText = " ", IsHeader = false, Url = "/link" }), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_UnknownMenu_ReturnsNull() + { + var dto = await _service.SaveItemsAsync(9999, + new LeftNavItemsSave { Items = new List() }, TestContext.Current.CancellationToken); + + Assert.Null(dto); + } + + [Fact] + public async Task SaveItems_UnknownItemId_Throws_AndDoesNotResurrect() + { + var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep"))); + + // A stale client posts an id that no longer exists in the menu. + await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId, + ItemsSave(menu, new LeftNavItemEdit { LeftNavItemId = 987654, MenuItemText = "Deleted Elsewhere" }), + TestContext.Current.CancellationToken)); + + // Nothing was created; the menu still holds only its original item. + Assert.Equal(1, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_DuplicateItemIds_ThrowsArgumentException() + { + var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep"))); + var keepId = menu.LeftNavItems.First().LeftNavItemId; + + await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId, + ItemsSave(menu, + new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "A" }, + new LeftNavItemEdit { LeftNavItemId = keepId, MenuItemText = "B" }), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_StaleLastModifiedOn_ThrowsConcurrency() + { + // Item saves ride on the menu's ModifiedOn; a stale stamp means another editor saved + // (settings or items) after this client loaded, and the batch must not clobber them. + var menu = await SeedMenuAsync(); + + var request = new LeftNavItemsSave + { + LastModifiedOn = menu.ModifiedOn.AddMinutes(-5), + Items = new List { new() { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true } } + }; + + var ex = await Assert.ThrowsAsync(() => + _service.SaveItemsAsync(menu.LeftNavMenuId, request, TestContext.Current.CancellationToken)); + Assert.Contains("modified by test", ex.Message); + Assert.Equal(0, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_NullItems_ThrowsArgumentException() + { + // "items": null binds past JsonRequired; it must be a 400, not a NullReferenceException, + // and must not be read as "delete everything". + var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep"))); + + await Assert.ThrowsAsync(() => _service.SaveItemsAsync(menu.LeftNavMenuId, + new LeftNavItemsSave { LastModifiedOn = menu.ModifiedOn, Items = null }, + TestContext.Current.CancellationToken)); + Assert.Equal(1, await _context.LeftNavItems.CountAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_MissingLastModifiedOn_ThrowsArgumentException() + { + var menu = await SeedMenuAsync(); + + var request = new LeftNavItemsSave + { + Items = new List { new() { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true } } + }; + + await Assert.ThrowsAsync(() => + _service.SaveItemsAsync(menu.LeftNavMenuId, request, TestContext.Current.CancellationToken)); + } + + private static LeftNavItemsSave ItemsSave(LeftNavMenu menu, params LeftNavItemEdit[] items) => + new() { LastModifiedOn = menu.ModifiedOn, Items = items.ToList() }; +} diff --git a/web/Areas/CMS/Controllers/CMSContentController.cs b/web/Areas/CMS/Controllers/CMSContentController.cs index 8b0399f31..fbf96d194 100644 --- a/web/Areas/CMS/Controllers/CMSContentController.cs +++ b/web/Areas/CMS/Controllers/CMSContentController.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using Viper.Areas.CMS.Constants; using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; using Viper.Classes; using Viper.Classes.SQLContext; -using Viper.Models.VIPER; +using Viper.Models; using Viper.Services; using Web.Authorization; @@ -16,150 +19,292 @@ public class CMSContentController : ApiController private readonly VIPERContext _context; private readonly RAPSContext _rapsContext; private readonly IHtmlSanitizerService _sanitizerService; - public IUserHelper UserHelper { get; private set; } + private readonly ICmsContentBlockService _blockService; + private readonly ICmsFileStorageService _storage; + private readonly IUserHelper _userHelper; - public CMSContentController(VIPERContext context, RAPSContext rapsContext, IHtmlSanitizerService sanitizerService) + public CMSContentController(VIPERContext context, RAPSContext rapsContext, + IHtmlSanitizerService sanitizerService, ICmsContentBlockService blockService, + ICmsFileStorageService storage, IUserHelper userHelper) { _context = context; _rapsContext = rapsContext; _sanitizerService = sanitizerService; - UserHelper = new UserHelper(); + _blockService = blockService; + _storage = storage; + _userHelper = userHelper; } //GET: content [HttpGet] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public ActionResult> GetContentBlocks() + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)] + public async Task>> GetContentBlocks( + ApiPagination? pagination, + string status = "active", + string? system = null, + string? viperSectionPath = null, + string? search = null, + bool? isPublic = null, + string? sortBy = "title", + bool descending = false, + CancellationToken ct = default) { - if (_context.ContentBlocks == null) + var page = pagination?.Page ?? 1; + var perPage = pagination?.PerPage ?? 50; + var (blocks, total) = await _blockService.GetContentBlocksAsync(status, system, viperSectionPath, search, + isPublic, page, perPage, sortBy, descending, ct); + if (pagination != null) { - return NotFound(); + pagination.TotalRecords = total; } - return new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks()?.ToList() ?? new List(); + return blocks; } - //GET: content/fn/{friendlyName} - [HttpGet("fn/{friendlyName}")] - public ActionResult GetContentBlockByFn(string friendlyName) + //GET: content/section-paths + [HttpGet("section-paths")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task>> GetViperSectionPaths(CancellationToken ct = default) { - var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, null)?.ToList(); - if (blocks == null || blocks.Count == 0) - { - return NotFound(); - } + return await _blockService.GetViperSectionPathsAsync(ct); + } - return blocks[0]; + // GET: content/folders + // The section path IS a file folder (legacy parity): a block's files live in this folder. + // Exposed to content-block editors so the section path can be chosen from the real upload + // folders without requiring AllFiles (the /api/cms/files/folders endpoint is AllFiles-gated). + [HttpGet("folders")] + [Permission(Allow = CmsPermissions.ManageContentBlocks + "," + CmsPermissions.CreateContentBlock)] + public ActionResult> GetFolders() + { + return _storage.GetTopLevelFolders(); } - //PUT: content/5 - [HttpPut("{contentBlockId}")] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block) + //GET: content/5 + [HttpGet("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> GetContentBlock(int contentBlockId, CancellationToken ct = default) { - //check data is valid and block is found - var existingBlock = await _context.ContentBlocks.FindAsync(contentBlockId); - if (existingBlock == null) + var block = await _blockService.GetContentBlockAsync(contentBlockId, ct); + if (block == null) { return NotFound(); } + return block; + } - if (contentBlockId != block.ContentBlockId) + //GET: content/fn/{friendlyName} — public display endpoint; permission filtering happens + //inside GetContentBlocksAllowed (public flag, block permissions, or CMS admin). + [HttpGet("fn/{friendlyName}")] + public ActionResult GetContentBlockByFn(string friendlyName) + { + // status: 1 = active only. A public display endpoint must never serve soft-deleted + // blocks (passing null would include DeletedOn != null rows). + var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService) + .GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, 1)?.ToList(); + if (blocks == null || blocks.Count == 0) { - return BadRequest(); + return NotFound(); } - string inputCheck = CheckBlockForRequiredFields(block); - if (!string.IsNullOrEmpty(inputCheck)) + // Project to the public DTO so this anonymous endpoint never serializes the raw entity + // graph (attached-file encryption Keys, server FilePaths, unsanitized ContentHistory, + // permission rows) nor management metadata (editor login ids, permission names, + // System/section placement). Content is already render-sanitized by + // GetContentBlocksAllowed; display consumers read only content + title. + return CmsContentBlockMapper.ToPublicDto(blocks[0]); + } + + //GET: content/5/history + [HttpGet("{contentBlockId:int}/history")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task>> GetHistory(int contentBlockId, CancellationToken ct = default) + { + return await _blockService.GetHistoryAsync(contentBlockId, ct); + } + + //GET: content/5/history/12 + [HttpGet("{contentBlockId:int}/history/{contentHistoryId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> GetHistoryVersion(int contentBlockId, int contentHistoryId, + CancellationToken ct = default) + { + var version = await _blockService.GetHistoryVersionAsync(contentBlockId, contentHistoryId, ct); + if (version == null) { - return BadRequest(inputCheck); + return NotFound(); } + return version; + } - var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault(); - if (friendlyNameCheck != null && friendlyNameCheck.ContentBlockId != contentBlockId) + //GET: content/5/history/12/diff — rendered diff of this version against the previous version + [HttpGet("{contentBlockId:int}/history/{contentHistoryId:int}/diff")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> GetHistoryVersionDiff(int contentBlockId, int contentHistoryId, + CancellationToken ct = default) + { + var diff = await _blockService.GetHistoryVersionDiffAsync(contentBlockId, contentHistoryId, ct); + if (diff == null) { - return ValidationProblem("Friendly name must be unique"); + return NotFound(); } + return diff; + } - if (friendlyNameCheck != null) + //POST: content/5/history/12/diff — rendered diff of a posted draft against this version. + //POST (not GET) because the editor's current content rides in the body. + [HttpPost("{contentBlockId:int}/history/{contentHistoryId:int}/diff")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> DiffAgainstHistoryVersion(int contentBlockId, int contentHistoryId, + DiffAgainstHistoryRequest request, CancellationToken ct = default) + { + var diff = await _blockService.DiffContentAgainstHistoryAsync(contentBlockId, contentHistoryId, request.Content, ct); + if (diff == null) { - _context.Entry(friendlyNameCheck).State = EntityState.Detached; + return NotFound(); } + return diff; + } - //modify database object - ModifyBlockWithUserInput(existingBlock, block); - _context.Entry(existingBlock).State = EntityState.Modified; - - //save history - var contentHistory = new ContentHistory + //GET: content/history — cross-block edit-history viewer (the literal segment does not + //collide with the int-constrained {contentBlockId}/history route). + [HttpGet("history")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)] + public async Task>> GetHistoryEntries( + int? contentBlockId, + string? modifiedBy, + DateTime? from, + DateTime? to, + string? search, + ApiPagination? pagination, + CancellationToken ct = default) + { + var filter = new CmsContentHistoryFilter { ContentBlockId = contentBlockId, - ContentBlockContent = block.Content, - ModifiedOn = DateTime.Now, - ModifiedBy = UserHelper.GetCurrentUser()?.LoginId + ModifiedBy = modifiedBy, + From = from, + To = to, + Search = search }; - _context.ContentHistories.Add(contentHistory); - - //save and return the saved block - await _context.SaveChangesAsync(); - var returnBlock = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault(); - if (returnBlock == null) + var page = pagination?.Page ?? 1; + var perPage = pagination?.PerPage ?? 50; + var entries = await _blockService.GetHistoryEntriesAsync(filter, page, perPage, ct); + if (pagination != null) { - return NotFound(); + pagination.TotalRecords = await _blockService.GetHistoryEntryCountAsync(filter, ct); } - return returnBlock; + return entries; } //POST: content [HttpPost] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> CreateContentBlock(CMSBlockAddEdit block) + [Permission(Allow = CmsPermissions.CreateContentBlock + "," + CmsPermissions.ManageContentBlocks)] + public async Task> CreateContentBlock(CMSBlockAddEdit block, CancellationToken ct = default) { string inputCheck = CheckBlockForRequiredFields(block); if (!string.IsNullOrEmpty(inputCheck)) { return BadRequest(inputCheck); } - var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault(); - if (friendlyNameCheck != null) + + try { - return ValidationProblem("Friendly name must be unique"); + return await _blockService.CreateContentBlockAsync(block, ct); } + catch (ArgumentException ex) + { + return ValidationProblem(ex.Message); + } + } - var newBlock = new ContentBlock(); - ModifyBlockWithUserInput(newBlock, block); - - _context.ContentBlocks.Add(newBlock); - await _context.SaveChangesAsync(); + //PUT: content/5 + [HttpPut("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block, + CancellationToken ct = default) + { + if (contentBlockId != block.ContentBlockId) + { + return BadRequest(); + } - var contentHistory = new ContentHistory + string inputCheck = CheckBlockForRequiredFields(block); + if (!string.IsNullOrEmpty(inputCheck)) { - ContentBlockId = block.ContentBlockId, - ContentBlockContent = block.Content, - ModifiedOn = DateTime.Now, - ModifiedBy = UserHelper.GetCurrentUser()?.LoginId - }; - _context.ContentHistories.Add(contentHistory); - await _context.SaveChangesAsync(); + return BadRequest(inputCheck); + } - return newBlock; + try + { + var updated = await _blockService.UpdateContentBlockAsync(contentBlockId, block, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + catch (CmsConcurrencyException ex) + { + return Conflict(ex.Message); + } + catch (ArgumentException ex) + { + return ValidationProblem(ex.Message); + } } - //DELETE: content/5 - [HttpDelete("{contentBlockId}")] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> DeleteContentBlock(int contentBlockId) + //PATCH: content/5/content — quick save for content-only edits + [HttpPatch("{contentBlockId:int}/content")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> UpdateContentOnly(int contentBlockId, + ContentOnlyUpdate update, CancellationToken ct = default) { - var block = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault(); - if (block == null) + try { - return NotFound(); + var updated = await _blockService.UpdateContentOnlyAsync(contentBlockId, update.Content, update.LastModifiedOn, ct); + if (updated == null) + { + return NotFound(); + } + return updated; } + catch (CmsConcurrencyException ex) + { + return Conflict(ex.Message); + } + catch (ArgumentException ex) + { + return ValidationProblem(ex.Message); + } + } - block.DeletedOn = DateTime.Now; - block.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId; - _context.Entry(block).State = EntityState.Modified; - await _context.SaveChangesAsync(); - return block; + //POST: content/5/restore + [HttpPost("{contentBlockId:int}/restore")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task RestoreContentBlock(int contentBlockId, CancellationToken ct = default) + { + return await _blockService.RestoreAsync(contentBlockId, ct) ? NoContent() : NotFound(); + } + + //DELETE: content/5?permanent=true|false + [HttpDelete("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task DeleteContentBlock(int contentBlockId, bool permanent = false, + CancellationToken ct = default) + { + if (permanent) + { + // Permanent delete removes history and cannot be undone; admin only, matching the + // legacy "dev only" (SVMSecure.CATS.Admin) gate on permanent content-block delete. + if (!_userHelper.HasPermission(_rapsContext, _userHelper.GetCurrentUser(), CmsPermissions.Admin)) + { + return ForbidApi(); + } + return await _blockService.PermanentlyDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound(); + } + return await _blockService.SoftDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound(); } private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput) @@ -175,39 +320,20 @@ private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput) } return errors; } + } - private void ModifyBlockWithUserInput(ContentBlock contentBlock, CMSBlockAddEdit userInput) - { - //update info - contentBlock.Title = userInput.Title; - contentBlock.Content = userInput.Content; - contentBlock.FriendlyName = userInput.FriendlyName; - contentBlock.System = userInput.System; - contentBlock.Application = userInput.Application; - contentBlock.Page = userInput.Page; - contentBlock.ViperSectionPath = userInput.ViperSectionPath; - contentBlock.AllowPublicAccess = userInput.AllowPublicAccess; - contentBlock.BlockOrder = userInput.BlockOrder; - contentBlock.ModifiedOn = DateTime.Now; - contentBlock.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId; - - //adjust permissions - //remove content block permisisons that are not in the user input - foreach (var cbp in contentBlock.ContentBlockToPermissions.Where(cbp => !userInput.Permissions.Contains(cbp.Permission))) - { - contentBlock.ContentBlockToPermissions.Remove(cbp); - } + public class ContentOnlyUpdate + { + // AllowEmptyStrings: clearing a block's content is a legitimate save; only a JSON null + // (which would bypass the non-nullable default) gets the automatic 400. + [Required(AllowEmptyStrings = true)] + public string Content { get; set; } = string.Empty; + public DateTime? LastModifiedOn { get; set; } + } - //add new content block permissions, if they are not in the existing list - var existingPermissions = contentBlock.ContentBlockToPermissions.Select(p => p.Permission).ToList(); - foreach (var p in userInput.Permissions.Where(p => !existingPermissions.Contains(p))) - { - contentBlock.ContentBlockToPermissions.Add(new ContentBlockToPermission - { - Permission = p, - ContentBlockId = userInput.ContentBlockId - }); - } - } + public class DiffAgainstHistoryRequest + { + [Required(AllowEmptyStrings = true)] + public string Content { get; set; } = string.Empty; } } diff --git a/web/Areas/CMS/Controllers/CMSLeftNavController.cs b/web/Areas/CMS/Controllers/CMSLeftNavController.cs new file mode 100644 index 000000000..957e23cc6 --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSLeftNavController.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// Left navigation menu management. Display of menus to end users goes through + /// LayoutController / Data.LeftNavMenu, which filters items by user permission. + /// + [Route("/api/cms/left-navs")] + [Permission(Allow = CmsPermissions.ManageNavigation)] + public class CMSLeftNavController : ApiController + { + private readonly ICmsLeftNavService _leftNavService; + + public CMSLeftNavController(ICmsLeftNavService leftNavService) + { + _leftNavService = leftNavService; + } + + // GET /api/cms/left-navs + [HttpGet] + public async Task>> GetMenus( + string? system, string? viperSectionPath, string? search, CancellationToken ct = default) + { + return await _leftNavService.GetMenusAsync(system, viperSectionPath, search, ct); + } + + // GET /api/cms/left-navs/5 + [HttpGet("{leftNavMenuId:int}")] + public async Task> GetMenu(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await _leftNavService.GetMenuAsync(leftNavMenuId, ct); + if (menu == null) + { + return NotFound(); + } + return menu; + } + + // POST /api/cms/left-navs + [HttpPost] + public async Task> CreateMenu(LeftNavMenuAddEdit menu, CancellationToken ct = default) + { + try + { + var created = await _leftNavService.CreateMenuAsync(menu, ct); + return CreatedAtAction(nameof(GetMenu), new { leftNavMenuId = created.LeftNavMenuId }, created); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + // PUT /api/cms/left-navs/5 + [HttpPut("{leftNavMenuId:int}")] + public async Task> UpdateMenu(int leftNavMenuId, LeftNavMenuAddEdit menu, + CancellationToken ct = default) + { + try + { + var updated = await _leftNavService.UpdateMenuAsync(leftNavMenuId, menu, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + // A stale edit must surface as 409 (someone saved first); CmsConcurrencyException + // derives from InvalidOperationException, so catch it explicitly rather than as a 500. + catch (CmsConcurrencyException ex) + { + return Conflict(ex.Message); + } + } + + // PUT /api/cms/left-navs/5/items — full item list plus the menu's concurrency stamp; + // order follows the array + [HttpPut("{leftNavMenuId:int}/items")] + public async Task> SaveItems(int leftNavMenuId, LeftNavItemsSave request, + CancellationToken ct = default) + { + try + { + var updated = await _leftNavService.SaveItemsAsync(leftNavMenuId, request, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + catch (ArgumentException ex) + { + // Malformed request (e.g. duplicate item ids, missing LastModifiedOn). + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + // Stale client: an outdated LastModifiedOn stamp (CmsConcurrencyException derives + // from InvalidOperationException) or a supplied item id no longer in the menu. + return Conflict(ex.Message); + } + } + + // DELETE /api/cms/left-navs/5 — deletes the menu and all items (no soft delete, matching legacy) + [HttpDelete("{leftNavMenuId:int}")] + public async Task DeleteMenu(int leftNavMenuId, CancellationToken ct = default) + { + return await _leftNavService.DeleteMenuAsync(leftNavMenuId, ct) ? NoContent() : NotFound(); + } + } +} diff --git a/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs b/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs index edfb0696c..95cd24bf7 100644 --- a/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs +++ b/web/Areas/CMS/Controllers/CMSLinkCollectionController.cs @@ -2,6 +2,7 @@ using Areas.CMS.Models.DTOs; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; using Viper.Classes; using Viper.Classes.SQLContext; using Web.Authorization; @@ -31,19 +32,13 @@ public async Task>> GetLinkCollectio { LinkCollectionId = lc.LinkCollectionId, LinkCollection = lc.LinkCollectionName, - LinkCollectionTagCategories = lc.LinkCollectionTagCategories.Select(tc => new LinkCollectionTagCategoryDto - { - LinkCollectionTagCategoryId = tc.LinkCollectionTagCategoryId, - LinkCollectionId = tc.LinkCollectionId, - LinkCollectionTagCategory = tc.LinkCollectionTagCategoryName, - SortOrder = tc.SortOrder - }).ToList() + LinkCollectionTagCategories = lc.LinkCollectionTagCategories.Select(ToTagCategoryDto).ToList() }); return Ok(result); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpPost] public async Task> PostLinkCollection(CreateLinkCollectionDto createDto) { @@ -68,10 +63,10 @@ public async Task> PostLinkCollection(CreateLink LinkCollectionTagCategories = new List() }; - return CreatedAtAction(nameof(linkCollection), new { id = linkCollection.LinkCollectionId }, result); + return CreatedAtAction(nameof(GetLinkCollections), new { linkCollectionName = linkCollection.LinkCollectionName }, result); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpPut("{id}")] public async Task PutLinkCollection(int id, CreateLinkCollectionDto updateDto) { @@ -94,7 +89,7 @@ public async Task PutLinkCollection(int id, CreateLinkCollectionD return Ok(updateDto); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpDelete("{id}")] public async Task DeleteLinkCollection(int id) { @@ -104,6 +99,18 @@ public async Task DeleteLinkCollection(int id) return NotFound(); } + // FK constraints are not ON DELETE CASCADE, so remove dependents before + // the collection. Queuing all removals into a single SaveChanges keeps it + // atomic. Order: link tags -> links -> tag categories -> collection. + var collectionLinks = await _context.Links + .Where(l => l.LinkCollectionId == id) + .ToListAsync(); + var collectionLinkIds = collectionLinks.Select(l => l.LinkId).ToList(); + _context.LinkTags.RemoveRange( + _context.LinkTags.Where(lt => EF.Parameter(collectionLinkIds).Contains(lt.LinkId))); + _context.Links.RemoveRange(collectionLinks); + _context.LinkCollectionTagCategories.RemoveRange( + _context.LinkCollectionTagCategories.Where(tc => tc.LinkCollectionId == id)); _context.LinkCollections.Remove(linkCollection); await _context.SaveChangesAsync(); @@ -124,28 +131,25 @@ public async Task>> GetLi .OrderBy(lc => lc.SortOrder) .ToListAsync(); - var results = categories - .Select(c => new LinkCollectionTagCategoryDto - { - LinkCollectionTagCategoryId = c.LinkCollectionTagCategoryId, - LinkCollectionId = c.LinkCollectionId, - LinkCollectionTagCategory = c.LinkCollectionTagCategoryName, - SortOrder = c.SortOrder - }) - .ToList(); + var results = categories.Select(ToTagCategoryDto).ToList(); return Ok(results); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpPost("{collectionId}/tags")] - public async Task> CreateLinkCollectionTagCategory(int collectionId, CreateLinkCollectionTagCategoryDto createLinkTagCategoryDto) + public async Task> CreateLinkCollectionTagCategory(int collectionId, CreateLinkCollectionTagCategoryDto createLinkTagCategoryDto) { if (!await _context.LinkCollections.AnyAsync(lc => lc.LinkCollectionId == collectionId)) { return NotFound(); } + if (createLinkTagCategoryDto.LinkCollectionId != collectionId) + { + return BadRequest("Collection ID in route does not match the request body."); + } + if (await _context.LinkCollectionTagCategories .AnyAsync(tc => tc.LinkCollectionTagCategoryName == createLinkTagCategoryDto.LinkCollectionTagCategory && tc.LinkCollectionId == collectionId)) @@ -161,10 +165,13 @@ public async Task> CreateLinkCo }; _context.LinkCollectionTagCategories.Add(tagCategory); await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(tagCategory), new { id = tagCategory.LinkCollectionTagCategoryId }, createLinkTagCategoryDto); + + // Return the saved DTO so the client receives the generated id. The follow-up + // tag-order PUT needs that id to address the newly created category. + return CreatedAtAction(nameof(GetLinkCollectionTagCategories), new { collectionId }, ToTagCategoryDto(tagCategory)); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpPut("{collectionId}/tags/order")] public async Task UpdateLinkCollectionTagCategoryOrder(int collectionId, List updateDto) { @@ -182,17 +189,25 @@ public async Task UpdateLinkCollectionTagCategoryOrder(int collec return BadRequest("Mismatch in number of tag categories."); } + // Membership + duplicate validation (a duplicated id with a matching count would + // silently update one row twice and skip another), mirroring UpdateLinkOrder. + var tagCategoriesById = tagCategories.ToDictionary(tc => tc.LinkCollectionTagCategoryId); + if (updateDto.Any(dto => !tagCategoriesById.ContainsKey(dto.LinkCollectionTagCategoryId)) + || updateDto.Select(dto => dto.LinkCollectionTagCategoryId).Distinct().Count() != updateDto.Count) + { + return BadRequest("One or more tag category IDs are invalid."); + } + foreach (var dto in updateDto) { - var tagCategory = tagCategories.First(tc => tc.LinkCollectionTagCategoryId == dto.LinkCollectionTagCategoryId); - tagCategory.SortOrder = dto.SortOrder; + tagCategoriesById[dto.LinkCollectionTagCategoryId].SortOrder = dto.SortOrder; } await _context.SaveChangesAsync(); return NoContent(); } - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] [HttpDelete("{collectionId}/tags/{tagCategoryId}")] public async Task DeleteLinkCollectionTagCategory(int collectionId, int tagCategoryId) { @@ -210,5 +225,13 @@ public async Task DeleteLinkCollectionTagCategory(int collectionI await _context.SaveChangesAsync(); return NoContent(); } + + private static LinkCollectionTagCategoryDto ToTagCategoryDto(LinkCollectionTagCategory tagCategory) => new() + { + LinkCollectionTagCategoryId = tagCategory.LinkCollectionTagCategoryId, + LinkCollectionId = tagCategory.LinkCollectionId, + LinkCollectionTagCategory = tagCategory.LinkCollectionTagCategoryName, + SortOrder = tagCategory.SortOrder + }; } } diff --git a/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs b/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs index f4ec728e9..d8ed38265 100644 --- a/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs +++ b/web/Areas/CMS/Controllers/CMSLinkCollectionLinks.cs @@ -117,7 +117,7 @@ public async Task> PostLink(int collectionId, CreateLinkDt _context.Links.Add(link); await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(link), new { id = link.LinkId }, new LinkDto + return CreatedAtAction(nameof(GetLinks), new { collectionId }, new LinkDto { LinkId = link.LinkId, LinkCollectionId = link.LinkCollectionId, @@ -190,15 +190,19 @@ public async Task UpdateLinkOrder(int collectionId, List l.LinkCollectionId == collectionId) .ToListAsync(); - if (links.Count != updateDto.Count) + // Membership validation, not just count: an unknown or duplicate LinkId slipped past the + // count-only guard and crashed the First() lookup below with a 500. + var linksById = links.ToDictionary(l => l.LinkId); + if (links.Count != updateDto.Count + || updateDto.Any(li => !linksById.ContainsKey(li.LinkId)) + || updateDto.Select(li => li.LinkId).Distinct().Count() != updateDto.Count) { return BadRequest("One or more LinkIds are invalid."); } foreach (var li in updateDto) { - var link = links.First(l => l.LinkId == li.LinkId); - link.SortOrder = li.SortOrder; + linksById[li.LinkId].SortOrder = li.SortOrder; } await _context.SaveChangesAsync(); diff --git a/web/Areas/CMS/Data/LeftNavMenu.cs b/web/Areas/CMS/Data/LeftNavMenu.cs index 6f9d83387..ac7c210f4 100644 --- a/web/Areas/CMS/Data/LeftNavMenu.cs +++ b/web/Areas/CMS/Data/LeftNavMenu.cs @@ -8,14 +8,13 @@ public class LeftNavMenu { private readonly VIPERContext? _viperContext; private readonly RAPSContext? _rapsContext; + private readonly IUserHelper _userHelper; - public IUserHelper UserHelper { get; private set; } - - public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext) + public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext, IUserHelper? userHelper = null) { this._viperContext = viperContext; this._rapsContext = rapsContext; - UserHelper = new UserHelper(); + _userHelper = userHelper ?? new UserHelper(); } /// @@ -46,14 +45,17 @@ public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext) return null; } - var currentUser = UserHelper.GetCurrentUser(); + var currentUser = _userHelper.GetCurrentUser(); List cmsMenus = new(); foreach (var m in menus) { - //by default, filter items based on user permissions + //by default, filter items based on user permissions; items with no + //permissions are visible to any LOGGED-IN user (legacy CMS behavior) - an + //anonymous request (e.g. an [AllowAnonymous] page rendering a menu) sees none List items = m.LeftNavItems .Where(item => !filterItemsByPermissions - || item.LeftNavItemToPermissions.Any(p => UserHelper.HasPermission(_rapsContext, currentUser, p.Permission))) + || (currentUser != null && item.LeftNavItemToPermissions.Count == 0) + || item.LeftNavItemToPermissions.Any(p => _userHelper.HasPermission(_rapsContext, currentUser, p.Permission))) .Select(item => new NavMenuItem(item)) .ToList(); cmsMenus.Add(new(m.MenuHeaderText, items)); diff --git a/web/Areas/CMS/Models/CMSBlockAddEdit.cs b/web/Areas/CMS/Models/CMSBlockAddEdit.cs index c1ba7dafe..fa9bac352 100644 --- a/web/Areas/CMS/Models/CMSBlockAddEdit.cs +++ b/web/Areas/CMS/Models/CMSBlockAddEdit.cs @@ -23,5 +23,13 @@ public class CMSBlockAddEdit public required bool AllowPublicAccess { get; set; } public ICollection Permissions { get; set; } = new List(); + + public List FileGuids { get; set; } = new(); + + /// + /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict. + /// Required for updates (null gets 400); ignored on create. + /// + public DateTime? LastModifiedOn { get; set; } } } diff --git a/web/Areas/CMS/Models/CmsContentBlockMapper.cs b/web/Areas/CMS/Models/CmsContentBlockMapper.cs new file mode 100644 index 000000000..fdbe2dcea --- /dev/null +++ b/web/Areas/CMS/Models/CmsContentBlockMapper.cs @@ -0,0 +1,36 @@ +using Riok.Mapperly.Abstractions; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Models.VIPER; + +namespace Viper.Areas.CMS.Models +{ + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class CmsContentBlockMapper + { + public static ContentBlockDto ToDto(ContentBlock block) + { + var dto = ToDtoBase(block); + dto.Permissions = block.ContentBlockToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList(); + dto.Files = block.ContentBlockToFiles + .Select(f => ToFileDto(f.FileGuid, f.File.FriendlyName)) + .OrderBy(f => f.FriendlyName) + .ToList(); + return dto; + } + + // Single source for an attached-file DTO's shape and public URL, shared with the + // content-block list projection so both build the friendly URL the same way. + public static ContentBlockFileDto ToFileDto(Guid fileGuid, string friendlyName) => new() + { + FileGuid = fileGuid, + FriendlyName = friendlyName, + Url = Data.CMS.GetFriendlyURL(friendlyName) + }; + + [MapperIgnoreTarget(nameof(ContentBlockDto.Permissions))] + [MapperIgnoreTarget(nameof(ContentBlockDto.Files))] + private static partial ContentBlockDto ToDtoBase(ContentBlock block); + + public static partial PublicContentBlockDto ToPublicDto(ContentBlock block); + } +} diff --git a/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs new file mode 100644 index 000000000..0aa693317 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs @@ -0,0 +1,89 @@ +namespace Viper.Areas.CMS.Models.DTOs +{ + public class ContentBlockDto + { + public int ContentBlockId { get; set; } + public string Content { get; set; } = string.Empty; + public string? Title { get; set; } + public string System { get; set; } = string.Empty; + public string? Application { get; set; } + public string? Page { get; set; } + public string? ViperSectionPath { get; set; } + public int? BlockOrder { get; set; } + public string? FriendlyName { get; set; } + public bool AllowPublicAccess { get; set; } + public DateTime ModifiedOn { get; set; } + public string ModifiedBy { get; set; } = string.Empty; + public DateTime? DeletedOn { get; set; } + public List Permissions { get; set; } = new(); + public List Files { get; set; } = new(); + } + + /// + /// Shape served by the anonymous display endpoint (content/fn/{friendlyName}). Carries only + /// what public rendering needs; the full ContentBlockDto would disclose editor login ids, + /// permission names, and placement metadata to unauthenticated callers. + /// + public class PublicContentBlockDto + { + public int ContentBlockId { get; set; } + public string Content { get; set; } = string.Empty; + public string? Title { get; set; } + public string? FriendlyName { get; set; } + } + + public class ContentBlockFileDto + { + public Guid FileGuid { get; set; } + public string FriendlyName { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + } + + public class ContentHistoryListItemDto + { + public int ContentHistoryId { get; set; } + public DateTime? ModifiedOn { get; set; } + public string? ModifiedBy { get; set; } + } + + public class ContentHistoryDto : ContentHistoryListItemDto + { + public string Content { get; set; } = string.Empty; + } + + /// + /// A rendered HTML diff between two versions of a content block. Content holds the merged + /// markup with htmldiff.net's ins/del (diffins/diffdel/diffmod) markers. The Old/New stamps + /// describe each side so the viewer can label the comparison direction. HasComparison is false + /// when there is no other version to compare against (e.g. the original version); Content then + /// holds the version's own markup with no diff markers. + /// + public class ContentHistoryDiffDto + { + public string Content { get; set; } = string.Empty; + public bool HasComparison { get; set; } + // False when the two compared versions are identical (the diff carries no ins/del markers), + // so the viewer can say "identical" instead of showing an unchanged body that looks broken. + public bool HasChanges { get; set; } + public DateTime? OldModifiedOn { get; set; } + public string? OldModifiedBy { get; set; } + public DateTime? NewModifiedOn { get; set; } + public string? NewModifiedBy { get; set; } + } + + /// + /// A single cross-block edit-history entry: a superseded content version with the block + /// it belongs to. Used by the content-block edit-history viewer. + /// + public class ContentHistoryAuditDto + { + public int ContentHistoryId { get; set; } + public int ContentBlockId { get; set; } + public string? Title { get; set; } + public string? FriendlyName { get; set; } + public string? Page { get; set; } + public DateTime? ModifiedOn { get; set; } + public string? ModifiedBy { get; set; } + public bool BlockDeleted { get; set; } + } +} diff --git a/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs new file mode 100644 index 000000000..24756851e --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs @@ -0,0 +1,102 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Viper.Areas.CMS.Validation; + +namespace Viper.Areas.CMS.Models.DTOs +{ + public class LeftNavMenuDto + { + public int LeftNavMenuId { get; set; } + public string? MenuHeaderText { get; set; } + public string System { get; set; } = string.Empty; + public string? ViperSectionPath { get; set; } + public string? Page { get; set; } + public string? FriendlyName { get; set; } + public DateTime ModifiedOn { get; set; } + public string ModifiedBy { get; set; } = string.Empty; + public List Items { get; set; } = new(); + } + + public class LeftNavItemDto + { + public int LeftNavItemId { get; set; } + public string? MenuItemText { get; set; } + public bool IsHeader { get; set; } + public string? Url { get; set; } + public int? DisplayOrder { get; set; } + public List Permissions { get; set; } = new(); + } + + // MaxLength values mirror the LeftNavMenu column sizes in VIPERContext so an over-long value + // is rejected as a 400 rather than surfacing as a 500 when SQL Server truncates/rejects it. + public class LeftNavMenuAddEdit + { + /// + /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict + /// and a missing value gets 400 (mirrors CMSBlockAddEdit.LastModifiedOn). + /// + public DateTime? LastModifiedOn { get; set; } + + [Required] + [MaxLength(100)] + public string MenuHeaderText { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string System { get; set; } = "Viper"; + + [MaxLength(100)] + public string? ViperSectionPath { get; set; } + + [MaxLength(200)] + public string? Page { get; set; } + + [MaxLength(100)] + public string? FriendlyName { get; set; } + } + + /// + /// Batch item save: the full item list plus the menu's concurrency stamp. Items ride on the + /// menu's ModifiedOn, so a stale stamp gets 409 and a missing one 400, mirroring + /// LeftNavMenuAddEdit.LastModifiedOn - without it, two editors' batch saves silently + /// overwrite each other's item order and permissions. + /// + public class LeftNavItemsSave + { + public DateTime? LastModifiedOn { get; set; } + + // JsonRequired: an under-posted (omitted) items property would default to an empty list + // and silently delete every item in the menu; deleting all items requires an explicit []. + // Nullable because "items": null still binds past JsonRequired; the service maps it to 400. + [JsonRequired] + public List? Items { get; set; } + } + + /// + /// Full item list for a menu; saving replaces the menu's items (new items have id 0, + /// omitted ids are deleted, order follows the array). + /// + public class LeftNavItemEdit + { + // JsonRequired (not defaulting on omission): an under-posted id would silently turn an + // update into a new item, and an omitted isHeader would silently clear a header. + [JsonRequired] + public int LeftNavItemId { get; set; } + + // Not [Required]: a header item may have empty text (legacy renders it as a blank + // spacer row). Links still need text - enforced in CmsLeftNavService.SaveItemsAsync. + [MaxLength(250)] + public string MenuItemText { get; set; } = string.Empty; + + [JsonRequired] + public bool IsHeader { get; set; } + + [MaxLength(250)] + [SafeUrl(AllowRelative = true)] + public string? Url { get; set; } + + // Each permission is stored in the varchar(500) LeftNavItemToPermission.permission column. + [MaxLengthEach(500)] + public List Permissions { get; set; } = new(); + } +} diff --git a/web/Areas/CMS/Services/CmsContentBlockService.cs b/web/Areas/CMS/Services/CmsContentBlockService.cs new file mode 100644 index 000000000..f82859cbd --- /dev/null +++ b/web/Areas/CMS/Services/CmsContentBlockService.cs @@ -0,0 +1,653 @@ +using Microsoft.EntityFrameworkCore; +using HtmlDiffer = HtmlDiff.HtmlDiff; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.VIPER; +using Viper.Services; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsContentBlockService + { + Task<(List Blocks, int Total)> GetContentBlocksAsync(string status, string? system, + string? viperSectionPath, string? search, bool? isPublic, int page, int perPage, string? sortBy, + bool descending, CancellationToken ct = default); + + Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default); + + Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default); + + Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default); + + Task UpdateContentOnlyAsync(int contentBlockId, string content, DateTime? lastModifiedOn, CancellationToken ct = default); + + Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default); + + Task RestoreAsync(int contentBlockId, CancellationToken ct = default); + + Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default); + + Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default); + + Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default); + + Task GetHistoryVersionDiffAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default); + + Task DiffContentAgainstHistoryAsync(int contentBlockId, int contentHistoryId, string currentContent, CancellationToken ct = default); + + Task> GetHistoryEntriesAsync(CmsContentHistoryFilter filter, int page, int perPage, CancellationToken ct = default); + + Task GetHistoryEntryCountAsync(CmsContentHistoryFilter filter, CancellationToken ct = default); + + Task> GetViperSectionPathsAsync(CancellationToken ct = default); + } + + /// + /// Filters for the cross-block edit-history viewer. Mirrors CmsFileAuditFilter so the + /// content-history page can reuse the file-audit page's filter UX. + /// + public class CmsContentHistoryFilter + { + public int? ContentBlockId { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public string? Search { get; set; } + } + + /// + /// Management operations for CMS content blocks. Mirrors legacy ColdFusion + /// ContentBlocks.cfc semantics: content is stored raw and sanitized on render, + /// and ContentHistory receives the PREVIOUS content/author before each update + /// so history entries are the older versions, not copies of the current one. + /// + public class CmsContentBlockService : ICmsContentBlockService + { + private readonly VIPERContext _context; + private readonly IHtmlSanitizerService _sanitizer; + private readonly IUserHelper _userHelper; + + public CmsContentBlockService(VIPERContext context, IHtmlSanitizerService sanitizer, IUserHelper userHelper) + { + _context = context; + _sanitizer = sanitizer; + _userHelper = userHelper; + } + + public async Task<(List Blocks, int Total)> GetContentBlocksAsync(string status, string? system, + string? viperSectionPath, string? search, bool? isPublic, int page, int perPage, string? sortBy, + bool descending, CancellationToken ct = default) + { + // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs. + // The upper bound stops a caller from defeating pagination with a giant page size. + page = Math.Max(page, 1); + perPage = Math.Clamp(perPage, 1, 500); + + var query = _context.ContentBlocks + .AsNoTracking() + .AsSplitQuery() + .TagWith("CmsContentBlockService.GetContentBlocks") + .AsQueryable(); + + query = status.ToLowerInvariant() switch + { + "active" => query.Where(b => b.DeletedOn == null), + "deleted" => query.Where(b => b.DeletedOn != null), + _ => query + }; + + if (!string.IsNullOrEmpty(system)) + { + query = query.Where(b => b.System == system); + } + if (!string.IsNullOrEmpty(viperSectionPath)) + { + query = query.Where(b => b.ViperSectionPath == viperSectionPath); + } + if (isPublic != null) + { + query = query.Where(b => b.AllowPublicAccess == isPublic); + } + if (!string.IsNullOrEmpty(search)) + { + // Coalescing null columns to "" (never a match: search is non-empty here) keeps + // this a flat OR chain the SQL and in-memory providers both translate. + query = query.Where(b => (b.Title ?? "").Contains(search) + || (b.FriendlyName ?? "").Contains(search) + || (b.Page ?? "").Contains(search) + || b.Content.Contains(search)); + } + + int total = await query.CountAsync(ct); + + query = (sortBy?.ToLowerInvariant(), descending) switch + { + ("vipersectionpath", false) => query.OrderBy(b => b.ViperSectionPath).ThenBy(b => b.Title), + ("vipersectionpath", true) => query.OrderByDescending(b => b.ViperSectionPath).ThenBy(b => b.Title), + ("page", false) => query.OrderBy(b => b.Page).ThenBy(b => b.Title), + ("page", true) => query.OrderByDescending(b => b.Page).ThenBy(b => b.Title), + ("modifiedon", false) => query.OrderBy(b => b.ModifiedOn), + ("modifiedon", true) => query.OrderByDescending(b => b.ModifiedOn), + (_, true) => query.OrderByDescending(b => b.Title), + _ => query.OrderBy(b => b.Title) + }; + + // List view: project without Content (it can be large) — the editor loads the full + // block. Two stages because GetFriendlyURL reads HttpContext and can't translate to SQL. + var blocks = await query + .Skip((page - 1) * perPage) + .Take(perPage) + .Select(b => new + { + b.ContentBlockId, + b.Title, + b.System, + b.Application, + b.Page, + b.ViperSectionPath, + b.BlockOrder, + b.FriendlyName, + b.AllowPublicAccess, + b.ModifiedOn, + b.ModifiedBy, + b.DeletedOn, + Permissions = b.ContentBlockToPermissions + .Select(p => p.Permission) + .OrderBy(p => p) + .ToList(), + Files = b.ContentBlockToFiles + .Select(f => new + { + f.FileGuid, + f.File.FriendlyName + }) + .OrderBy(f => f.FriendlyName) + .ToList() + }) + .ToListAsync(ct); + + var dtos = blocks.Select(b => new ContentBlockDto + { + ContentBlockId = b.ContentBlockId, + Title = b.Title, + System = b.System, + Application = b.Application, + Page = b.Page, + ViperSectionPath = b.ViperSectionPath, + BlockOrder = b.BlockOrder, + FriendlyName = b.FriendlyName, + AllowPublicAccess = b.AllowPublicAccess, + ModifiedOn = b.ModifiedOn, + ModifiedBy = b.ModifiedBy, + DeletedOn = b.DeletedOn, + Permissions = b.Permissions, + Files = b.Files.Select(f => CmsContentBlockMapper.ToFileDto(f.FileGuid, f.FriendlyName)).ToList() + }).ToList(); + + return (dtos, total); + } + + public async Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: false, ct); + if (block == null) + { + return null; + } + var dto = CmsContentBlockMapper.ToDto(block); + // Sanitize for the editor with the same policy used on render, so what the + // editor shows matches what viewers will see. + dto.Content = _sanitizer.Sanitize(dto.Content); + return dto; + } + + public async Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default) + { + await AssertFriendlyNameUniqueAsync(request.FriendlyName, null, ct); + var fileGuids = (request.FileGuids ?? new List()).Distinct().ToList(); + await AssertFilesExistAsync(fileGuids, ct); + + var block = new ContentBlock(); + ApplyScalarFields(block, request); + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + foreach (var permission in CleanList(request.Permissions)) + { + block.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = permission }); + } + foreach (var fileGuid in fileGuids) + { + block.ContentBlockToFiles.Add(new ContentBlockToFile { FileGuid = fileGuid }); + } + + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(ct); + + return (await GetContentBlockAsync(block.ContentBlockId, ct))!; + } + + public async Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: true, ct); + if (block == null) + { + return null; + } + + AssertNotStale(block, request.LastModifiedOn); + await AssertFriendlyNameUniqueAsync(request.FriendlyName, contentBlockId, ct); + var fileGuids = (request.FileGuids ?? new List()).Distinct().ToList(); + await AssertFilesExistAsync(fileGuids, ct); + + SavePreviousVersionToHistory(block); + ApplyScalarFields(block, request); + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + ApplyPermissionDeltas(block, CleanList(request.Permissions)); + ApplyFileDeltas(block, fileGuids); + + await _context.SaveChangesAsync(ct); + return await GetContentBlockAsync(contentBlockId, ct); + } + + public async Task UpdateContentOnlyAsync(int contentBlockId, string content, + DateTime? lastModifiedOn, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: true, ct); + if (block == null) + { + return null; + } + + AssertNotStale(block, lastModifiedOn); + SavePreviousVersionToHistory(block); + block.Content = content; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return await GetContentBlockAsync(contentBlockId, ct); + } + + public async Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct); + if (block == null) + { + return false; + } + block.DeletedOn = DateTime.Now; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task RestoreAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct); + if (block == null) + { + return false; + } + block.DeletedOn = null; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks + .Include(b => b.ContentBlockToPermissions) + .Include(b => b.ContentBlockToFiles) + .Include(b => b.ContentHistories) + .AsSplitQuery() + .FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct); + if (block == null) + { + return false; + } + + _context.RemoveRange(block.ContentHistories); + _context.RemoveRange(block.ContentBlockToFiles); + _context.RemoveRange(block.ContentBlockToPermissions); + _context.Remove(block); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default) + { + return await _context.ContentHistories + .AsNoTracking() + .Where(h => h.ContentBlockId == contentBlockId) + .OrderByDescending(h => h.ModifiedOn) + .ThenByDescending(h => h.ContentHistoryId) + .Select(h => new ContentHistoryListItemDto + { + ContentHistoryId = h.ContentHistoryId, + ModifiedOn = h.ModifiedOn, + ModifiedBy = h.ModifiedBy + }) + .ToListAsync(ct); + } + + public async Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default) + { + var history = await _context.ContentHistories + .AsNoTracking() + .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct); + if (history == null) + { + return null; + } + return new ContentHistoryDto + { + ContentHistoryId = history.ContentHistoryId, + ModifiedOn = history.ModifiedOn, + ModifiedBy = history.ModifiedBy, + Content = _sanitizer.Sanitize(history.ContentBlockContent) + }; + } + + public async Task GetHistoryVersionDiffAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default) + { + var selected = await _context.ContentHistories + .AsNoTracking() + .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct); + if (selected == null) + { + return null; + } + + // The "previous version" is the next-older history row for this block, by the same + // newest-first ordering the history list uses. History holds superseded versions only, + // so the current live block is never the comparison target here. + var previous = await _context.ContentHistories + .AsNoTracking() + .Where(h => h.ContentBlockId == contentBlockId + && (h.ModifiedOn < selected.ModifiedOn + || (h.ModifiedOn == selected.ModifiedOn && h.ContentHistoryId < selected.ContentHistoryId))) + .OrderByDescending(h => h.ModifiedOn) + .ThenByDescending(h => h.ContentHistoryId) + .FirstOrDefaultAsync(ct); + + if (previous == null) + { + // Original version: nothing older to diff against. Return its own sanitized markup. + return new ContentHistoryDiffDto + { + Content = _sanitizer.Sanitize(selected.ContentBlockContent), + HasComparison = false, + NewModifiedOn = selected.ModifiedOn, + NewModifiedBy = selected.ModifiedBy + }; + } + + var diffHtml = BuildDiffHtml(previous.ContentBlockContent, selected.ContentBlockContent); + return new ContentHistoryDiffDto + { + Content = diffHtml, + HasComparison = true, + HasChanges = DiffHasChanges(diffHtml), + OldModifiedOn = previous.ModifiedOn, + OldModifiedBy = previous.ModifiedBy, + NewModifiedOn = selected.ModifiedOn, + NewModifiedBy = selected.ModifiedBy + }; + } + + public async Task DiffContentAgainstHistoryAsync(int contentBlockId, int contentHistoryId, string currentContent, CancellationToken ct = default) + { + var selected = await _context.ContentHistories + .AsNoTracking() + .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct); + if (selected == null) + { + return null; + } + + // The history version is the "old" side and the editor's current draft is the "new" side, + // so ins/del read as what the draft adds/removes relative to that version. + var diffHtml = BuildDiffHtml(selected.ContentBlockContent, currentContent); + return new ContentHistoryDiffDto + { + Content = diffHtml, + HasComparison = true, + HasChanges = DiffHasChanges(diffHtml), + OldModifiedOn = selected.ModifiedOn, + OldModifiedBy = selected.ModifiedBy + }; + } + + // Sanitize both sides with the render-time policy first so the diff compares what actually + // renders, then diff, then re-sanitize the merged result. The second pass re-parses through + // AngleSharp (balancing/closing the malformed tags htmldiff.net can emit) and strips anything + // off-policy, so we no longer trust the diff library's output — SanitizeDiff keeps only the + // / change markers on top of our normal allowlist. + private string BuildDiffHtml(string oldContent, string newContent) + { + var oldSafe = _sanitizer.Sanitize(oldContent); + var newSafe = _sanitizer.Sanitize(newContent); + var diff = HtmlDiffer.Execute(oldSafe, newSafe); + return _sanitizer.SanitizeDiff(diff); + } + + // htmldiff.net only wraps actual changes in /; identical inputs come back with + // neither. Their absence means the two versions render the same. + private static bool DiffHasChanges(string diffHtml) + { + return diffHtml.Contains("> GetHistoryEntriesAsync(CmsContentHistoryFilter filter, int page, int perPage, CancellationToken ct = default) + { + // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs. + // The upper bound stops a caller from defeating pagination with a giant page size. + page = Math.Max(page, 1); + perPage = Math.Clamp(perPage, 1, 500); + + return await BuildHistoryQuery(filter) + .OrderByDescending(x => x.History.ModifiedOn) + .ThenByDescending(x => x.History.ContentHistoryId) + .Skip((page - 1) * perPage) + .Take(perPage) + .Select(x => new ContentHistoryAuditDto + { + ContentHistoryId = x.History.ContentHistoryId, + ContentBlockId = x.History.ContentBlockId, + Title = x.Block.Title, + FriendlyName = x.Block.FriendlyName, + Page = x.Block.Page, + ModifiedOn = x.History.ModifiedOn, + ModifiedBy = x.History.ModifiedBy, + BlockDeleted = x.Block.DeletedOn != null + }) + .ToListAsync(ct); + } + + public async Task GetHistoryEntryCountAsync(CmsContentHistoryFilter filter, CancellationToken ct = default) + { + return await BuildHistoryQuery(filter).CountAsync(ct); + } + + // Join history to its block so the block's title/page are filterable and projectable + // (a join, not a correlated subquery). Each row is a superseded prior version. + private IQueryable BuildHistoryQuery(CmsContentHistoryFilter filter) + { + var query = + from h in _context.ContentHistories.AsNoTracking() + join b in _context.ContentBlocks.AsNoTracking() on h.ContentBlockId equals b.ContentBlockId + select new HistoryWithBlock { History = h, Block = b }; + + if (filter.ContentBlockId != null) + { + query = query.Where(x => x.History.ContentBlockId == filter.ContentBlockId); + } + if (!string.IsNullOrEmpty(filter.ModifiedBy)) + { + query = query.Where(x => x.History.ModifiedBy == filter.ModifiedBy); + } + if (filter.From != null) + { + query = query.Where(x => x.History.ModifiedOn >= filter.From); + } + if (filter.To != null) + { + // Treat the To date as inclusive through end of day. + var to = DateRangeHelper.ExclusiveUpperBound(filter.To.Value); + query = query.Where(x => x.History.ModifiedOn < to); + } + if (!string.IsNullOrEmpty(filter.Search)) + { + query = query.Where(x => (x.Block.Title ?? "").Contains(filter.Search) + || (x.Block.FriendlyName ?? "").Contains(filter.Search) + || (x.Block.Page ?? "").Contains(filter.Search)); + } + return query; + } + + public async Task> GetViperSectionPathsAsync(CancellationToken ct = default) + { + return await _context.ContentBlocks + .AsNoTracking() + .Where(b => b.ViperSectionPath != null && b.ViperSectionPath != "") + .Select(b => b.ViperSectionPath!) + .Distinct() + .OrderBy(p => p) + .ToListAsync(ct); + } + + private async Task LoadBlockAsync(int contentBlockId, bool tracking, CancellationToken ct) + { + var query = _context.ContentBlocks + .Include(b => b.ContentBlockToPermissions) + .Include(b => b.ContentBlockToFiles) + .ThenInclude(f => f.File) + .AsSplitQuery(); + if (!tracking) + { + query = query.AsNoTracking(); + } + return await query.FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct); + } + + private static void AssertNotStale(ContentBlock block, DateTime? lastModifiedOn) => + CmsServiceHelpers.AssertNotStale("content block", block.ModifiedOn, block.ModifiedBy, lastModifiedOn); + + private async Task AssertFilesExistAsync(List fileGuids, CancellationToken ct) + { + if (fileGuids.Count == 0) + { + return; + } + int existing = await _context.Files.CountAsync(f => EF.Parameter(fileGuids).Contains(f.FileGuid), ct); + if (existing != fileGuids.Count) + { + throw new ArgumentException("One or more attached files do not exist."); + } + } + + private async Task AssertFriendlyNameUniqueAsync(string? friendlyName, int? exceptBlockId, CancellationToken ct) + { + if (string.IsNullOrEmpty(friendlyName)) + { + return; + } + // Case-insensitive like the left-nav sibling: SQL Server's default collation treats + // "Name" and "name" as equal, so the pre-check must too (and so must test providers). + var normalized = friendlyName.ToLowerInvariant(); + bool taken = await _context.ContentBlocks + .AnyAsync(b => b.FriendlyName != null && b.FriendlyName.ToLower() == normalized + && (exceptBlockId == null || b.ContentBlockId != exceptBlockId), ct); + if (taken) + { + throw new ArgumentException("Friendly name must be unique."); + } + } + + private void SavePreviousVersionToHistory(ContentBlock block) + { + // Store the version being replaced, stamped with ITS author/time (legacy semantics). + _context.ContentHistories.Add(new ContentHistory + { + ContentBlockId = block.ContentBlockId, + ContentBlockContent = block.Content, + ModifiedOn = block.ModifiedOn, + ModifiedBy = block.ModifiedBy + }); + } + + private static void ApplyScalarFields(ContentBlock block, CMSBlockAddEdit request) + { + block.Title = request.Title; + block.Content = request.Content; + block.FriendlyName = request.FriendlyName; + block.System = request.System; + block.Application = request.Application; + block.Page = request.Page; + block.ViperSectionPath = request.ViperSectionPath; + block.AllowPublicAccess = request.AllowPublicAccess; + block.BlockOrder = request.BlockOrder; + } + + private void ApplyPermissionDeltas(ContentBlock block, List requested) + { + var existing = block.ContentBlockToPermissions.ToList(); + foreach (var cbp in existing.Where(cbp => !requested.Contains(cbp.Permission, StringComparer.OrdinalIgnoreCase))) + { + block.ContentBlockToPermissions.Remove(cbp); + _context.Remove(cbp); + } + var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var permission in requested.Where(p => !existingNames.Contains(p))) + { + block.ContentBlockToPermissions.Add(new ContentBlockToPermission + { + ContentBlockId = block.ContentBlockId, + Permission = permission + }); + } + } + + private void ApplyFileDeltas(ContentBlock block, List requested) + { + var existing = block.ContentBlockToFiles.ToList(); + foreach (var cbf in existing.Where(cbf => !requested.Contains(cbf.FileGuid))) + { + block.ContentBlockToFiles.Remove(cbf); + _context.Remove(cbf); + } + var existingGuids = existing.Select(f => f.FileGuid).ToHashSet(); + foreach (var fileGuid in requested.Where(g => !existingGuids.Contains(g))) + { + block.ContentBlockToFiles.Add(new ContentBlockToFile + { + ContentBlockId = block.ContentBlockId, + FileGuid = fileGuid + }); + } + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + + // Wrapper so the history/block join can be filtered and ordered before projection. + private sealed class HistoryWithBlock + { + public ContentHistory History { get; set; } = null!; + public ContentBlock Block { get; set; } = null!; + } + + private static List CleanList(ICollection? values) => CmsServiceHelpers.CleanList(values); + } +} diff --git a/web/Areas/CMS/Services/CmsLeftNavService.cs b/web/Areas/CMS/Services/CmsLeftNavService.cs new file mode 100644 index 000000000..11a28f3b6 --- /dev/null +++ b/web/Areas/CMS/Services/CmsLeftNavService.cs @@ -0,0 +1,321 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsLeftNavService + { + Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default); + + Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default); + + Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default); + + Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default); + + Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default); + + /// + /// Replace the menu's items with the supplied list: items with id 0 are added, + /// existing ids are updated, omitted ids are deleted, and DisplayOrder follows + /// the array order. Matches the legacy editor's batch save. The request's + /// LastModifiedOn stamp guards the whole batch against concurrent saves. + /// + Task SaveItemsAsync(int leftNavMenuId, LeftNavItemsSave request, CancellationToken ct = default); + } + + /// + /// Management operations for CMS left navigation menus (legacy LeftNavs.cfc / + /// LeftNavAjax.cfc). Menu deletes cascade to items and item permissions; there is + /// no soft delete for menus, matching legacy. + /// + public class CmsLeftNavService : ICmsLeftNavService + { + private readonly VIPERContext _context; + private readonly IUserHelper _userHelper; + + public CmsLeftNavService(VIPERContext context, IUserHelper userHelper) + { + _context = context; + _userHelper = userHelper; + } + + public async Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default) + { + var query = _context.LeftNavMenus + .AsNoTracking() + .Include(m => m.LeftNavItems) + .ThenInclude(i => i.LeftNavItemToPermissions) + .AsSplitQuery() + .TagWith("CmsLeftNavService.GetMenus") + .AsQueryable(); + + if (!string.IsNullOrEmpty(system)) + { + query = query.Where(m => m.System == system); + } + if (!string.IsNullOrEmpty(viperSectionPath)) + { + query = query.Where(m => m.ViperSectionPath == viperSectionPath); + } + if (!string.IsNullOrEmpty(search)) + { + query = query.Where(m => (m.MenuHeaderText != null && m.MenuHeaderText.Contains(search)) + || (m.FriendlyName != null && m.FriendlyName.Contains(search)) + || (m.Page != null && m.Page.Contains(search))); + } + + var menus = await query + .OrderBy(m => m.MenuHeaderText) + .ToListAsync(ct); + return menus.Select(ToDto).ToList(); + } + + public async Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: false, ct); + return menu == null ? null : ToDto(menu); + } + + public async Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default) + { + await AssertFriendlyNameUniqueAsync(request.FriendlyName, null, ct); + + var menu = new LeftNavMenu(); + ApplyMenuFields(menu, request); + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + _context.LeftNavMenus.Add(menu); + await _context.SaveChangesAsync(ct); + return ToDto(menu); + } + + public async Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return null; + } + + AssertNotStale(menu, request.LastModifiedOn); + await AssertFriendlyNameUniqueAsync(request.FriendlyName, leftNavMenuId, ct); + + ApplyMenuFields(menu, request); + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return ToDto(menu); + } + + public async Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return false; + } + + foreach (var item in menu.LeftNavItems) + { + _context.RemoveRange(item.LeftNavItemToPermissions); + } + _context.RemoveRange(menu.LeftNavItems); + _context.Remove(menu); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task SaveItemsAsync(int leftNavMenuId, LeftNavItemsSave request, CancellationToken ct = default) + { + // JsonRequired on the DTO catches an omitted property, but an explicit "items": null + // still binds; 400 rather than a NullReferenceException 500. + var items = request.Items + ?? throw new ArgumentException("Items is required - send an empty array to remove all items."); + + // Reject duplicate ids up front so every caller gets the guard (was in the controller). + if (items.Where(i => i.LeftNavItemId > 0).GroupBy(i => i.LeftNavItemId).Any(g => g.Count() > 1)) + { + throw new ArgumentException("Duplicate item ids are not allowed."); + } + + // Links need text; a header may be blank - legacy menus use empty headers as spacer + // rows and the display side still renders them that way. + if (items.Any(i => !i.IsHeader && string.IsNullOrWhiteSpace(i.MenuItemText))) + { + throw new ArgumentException("Every link needs text - fill in the empty link before saving."); + } + + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return null; + } + + // Item saves bump the menu's ModifiedOn, so the same stamp guards both endpoints: + // without this, two editors' batch saves silently overwrite each other's items. + AssertNotStale(menu, request.LastModifiedOn); + + var existingById = menu.LeftNavItems.ToDictionary(i => i.LeftNavItemId); + + // A supplied id that isn't in this menu means the client's copy is stale (the item was + // deleted, or belongs to another menu). Reject rather than silently resurrecting it as a + // new row. + if (items.Any(i => i.LeftNavItemId > 0 && !existingById.ContainsKey(i.LeftNavItemId))) + { + throw new InvalidOperationException( + "One or more items no longer exist in this menu. Reload and try again."); + } + + var requestedIds = items.Where(i => i.LeftNavItemId > 0).Select(i => i.LeftNavItemId).ToHashSet(); + + // Delete items not present in the request. + foreach (var item in menu.LeftNavItems.Where(i => !requestedIds.Contains(i.LeftNavItemId)).ToList()) + { + _context.RemoveRange(item.LeftNavItemToPermissions); + _context.Remove(item); + menu.LeftNavItems.Remove(item); + } + + int order = 1; + foreach (var requested in items) + { + if (requested.LeftNavItemId > 0 && existingById.TryGetValue(requested.LeftNavItemId, out var existing)) + { + existing.MenuItemText = requested.MenuItemText; + existing.IsHeader = requested.IsHeader; + existing.Url = requested.IsHeader ? null : requested.Url; + existing.DisplayOrder = order; + ApplyItemPermissionDeltas(existing, CleanList(requested.Permissions)); + } + else + { + var newItem = new LeftNavItem + { + LeftNavMenuId = leftNavMenuId, + MenuItemText = requested.MenuItemText, + IsHeader = requested.IsHeader, + Url = requested.IsHeader ? null : requested.Url, + DisplayOrder = order + }; + foreach (var permission in CleanList(requested.Permissions)) + { + newItem.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = permission }); + } + menu.LeftNavItems.Add(newItem); + } + order++; + } + + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return await GetMenuAsync(leftNavMenuId, ct); + } + + // FriendlyName resolves menus in LayoutController via FirstOrDefault, so duplicates make + // resolution nondeterministic. Reject them case-insensitively at write time. SQL Server's + // default collation is case-insensitive; ToLower keeps the check correct on other providers. + private async Task AssertFriendlyNameUniqueAsync(string? friendlyName, int? exceptMenuId, CancellationToken ct) + { + if (string.IsNullOrEmpty(friendlyName)) + { + return; + } + + // Invariant on the client side (culture-sensitive ToLower mishandles e.g. Turkish i); + // the EF-side ToLower below translates to SQL LOWER and never runs in .NET. + var normalized = friendlyName.ToLowerInvariant(); + bool taken = await _context.LeftNavMenus + .AnyAsync(m => m.FriendlyName != null && m.FriendlyName.ToLower() == normalized + && (exceptMenuId == null || m.LeftNavMenuId != exceptMenuId), ct); + if (taken) + { + throw new ArgumentException("Friendly name must be unique."); + } + } + + private static void AssertNotStale(LeftNavMenu menu, DateTime? lastModifiedOn) => + CmsServiceHelpers.AssertNotStale("menu", menu.ModifiedOn, menu.ModifiedBy, lastModifiedOn); + + private async Task LoadMenuAsync(int leftNavMenuId, bool tracking, CancellationToken ct) + { + var query = _context.LeftNavMenus + .Include(m => m.LeftNavItems) + .ThenInclude(i => i.LeftNavItemToPermissions) + .AsSplitQuery(); + if (!tracking) + { + query = query.AsNoTracking(); + } + return await query.FirstOrDefaultAsync(m => m.LeftNavMenuId == leftNavMenuId, ct); + } + + private void ApplyItemPermissionDeltas(LeftNavItem item, List requested) + { + var existing = item.LeftNavItemToPermissions.ToList(); + foreach (var p in existing.Where(p => !requested.Contains(p.Permission, StringComparer.OrdinalIgnoreCase))) + { + item.LeftNavItemToPermissions.Remove(p); + _context.Remove(p); + } + var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var permission in requested.Where(p => !existingNames.Contains(p))) + { + item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission + { + LeftNavItemId = item.LeftNavItemId, + Permission = permission + }); + } + } + + private static void ApplyMenuFields(LeftNavMenu menu, LeftNavMenuAddEdit request) + { + menu.MenuHeaderText = request.MenuHeaderText; + menu.System = request.System; + menu.ViperSectionPath = request.ViperSectionPath; + menu.Page = request.Page; + menu.FriendlyName = request.FriendlyName; + } + + private static LeftNavMenuDto ToDto(LeftNavMenu menu) + { + return new LeftNavMenuDto + { + LeftNavMenuId = menu.LeftNavMenuId, + MenuHeaderText = menu.MenuHeaderText, + System = menu.System, + ViperSectionPath = menu.ViperSectionPath, + Page = menu.Page, + FriendlyName = menu.FriendlyName, + ModifiedOn = menu.ModifiedOn, + ModifiedBy = menu.ModifiedBy, + Items = menu.LeftNavItems + .OrderBy(i => i.DisplayOrder) + .Select(i => new LeftNavItemDto + { + LeftNavItemId = i.LeftNavItemId, + MenuItemText = i.MenuItemText, + IsHeader = i.IsHeader, + Url = i.Url, + DisplayOrder = i.DisplayOrder, + Permissions = i.LeftNavItemToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList() + }) + .ToList() + }; + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + + private static List CleanList(List? values) => CmsServiceHelpers.CleanList(values); + } +} diff --git a/web/Areas/CMS/Services/CmsNavMenu.cs b/web/Areas/CMS/Services/CmsNavMenu.cs index 0b151aeee..4d2b893d6 100644 --- a/web/Areas/CMS/Services/CmsNavMenu.cs +++ b/web/Areas/CMS/Services/CmsNavMenu.cs @@ -1,3 +1,4 @@ +using Viper.Areas.CMS.Constants; using Viper.Classes; using Viper.Classes.SQLContext; @@ -6,20 +7,60 @@ namespace Viper.Areas.CMS.Services public class CmsNavMenu { private readonly RAPSContext _rapsContext; - public CmsNavMenu(RAPSContext context) + private readonly IUserHelper _userHelper; + + public CmsNavMenu(RAPSContext context, IUserHelper? userHelper = null) { _rapsContext = context; + _userHelper = userHelper ?? new UserHelper(); } public NavMenu Nav() { - UserHelper userHelper = new UserHelper(); + var user = _userHelper.GetCurrentUser(); + bool canManageBlocks = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks); + bool canCreateBlocks = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.CreateContentBlock); + bool canManageFiles = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.AllFiles); + bool canManageNav = _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageNavigation); var nav = new List(); - if (userHelper.HasPermission(_rapsContext, userHelper.GetCurrentUser(), "SVMSecure.CMS.ManageContentBlocks")) + if (canManageBlocks || canCreateBlocks || canManageFiles || canManageNav) + { + nav.Add(new NavMenuItem { MenuItemText = "Home", MenuItemURL = "Home", IndentLevel = 1 }); + } + + if (canManageBlocks || canCreateBlocks) + { + nav.Add(new NavMenuItem { MenuItemText = "Content Blocks", IsHeader = true }); + if (canManageBlocks) + { + nav.Add(new NavMenuItem { MenuItemText = "Manage Content Blocks", MenuItemURL = "ManageContentBlocks", IndentLevel = 1 }); + } + if (canCreateBlocks) + { + nav.Add(new NavMenuItem { MenuItemText = "Add Content Block", MenuItemURL = "ManageContentBlocks/Edit", IndentLevel = 1 }); + } + if (canManageBlocks) + { + nav.Add(new NavMenuItem { MenuItemText = "Edit History", MenuItemURL = "ManageContentBlocks/History", IndentLevel = 1 }); + nav.Add(new NavMenuItem { MenuItemText = "Manage Link Collections", MenuItemURL = "ManageLinkCollections", IndentLevel = 1 }); + } + } + + if (canManageFiles) + { + nav.Add(new NavMenuItem { MenuItemText = "Files", IsHeader = true }); + nav.Add(new NavMenuItem { MenuItemText = "Manage Files", MenuItemURL = "ManageFiles", IndentLevel = 1 }); + nav.Add(new NavMenuItem { MenuItemText = "Add File", MenuItemURL = "ManageFiles?upload=1", IndentLevel = 1 }); + nav.Add(new NavMenuItem { MenuItemText = "Audit Trail", MenuItemURL = "ManageFiles/Audit", IndentLevel = 1 }); + } + + if (canManageNav) { - nav.Add(new NavMenuItem { MenuItemText = "Manage Link Collections", MenuItemURL = "ManageLinkCollections" }); + nav.Add(new NavMenuItem { MenuItemText = "Left Navigation Menus", IsHeader = true }); + nav.Add(new NavMenuItem { MenuItemText = "Manage Left-Nav Menus", MenuItemURL = "ManageLeftNav", IndentLevel = 1 }); + nav.Add(new NavMenuItem { MenuItemText = "Add Left-Nav Menu", MenuItemURL = "ManageLeftNav?add=1", IndentLevel = 1 }); } return new NavMenu("Content Management System", nav);