From 88d9ebfe6a077c952255f8124b3c539a0149f055 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Mon, 15 Jun 2026 18:09:17 -0700 Subject: [PATCH 1/6] VPR-59 feat(cms): add mobile card mode to list tables and polish a11y - List pages (Files, Content Blocks, Left Nav, Audit, Bulk Encrypt) collapse to stacked cards below the mobile breakpoint instead of scrolling wide tables - a11y: give the rich-text editor an accessible name, mark the System select required, and make the Link card keyboard-reachable via its anchor - Align the file and Effort audit-trail filter layouts (outlined controls; search and Clear Filters on their own row); this accounts for the Effort/AuditList change in a CMS commit - Replace remaining hard-coded colors with brand tokens and move inline dialog widths to shared classes --- VueApp/src/CMS/components/FileFormDialog.vue | 4 +- .../src/CMS/components/LeftNavMenuDialog.vue | 2 +- VueApp/src/CMS/components/Link.vue | 19 +---- VueApp/src/CMS/components/LinkCollections.vue | 1 + VueApp/src/CMS/components/ListCard.vue | 23 ++++++ VueApp/src/CMS/components/ListCardField.vue | 14 ++++ VueApp/src/CMS/components/ModifiedStamp.vue | 33 ++++++-- VueApp/src/CMS/pages/BulkEncrypt.vue | 25 ++++++ VueApp/src/CMS/pages/ContentBlockEdit.vue | 14 +++- VueApp/src/CMS/pages/ContentBlocks.vue | 74 ++++++++++++++++++ VueApp/src/CMS/pages/FileAuditLog.vue | 41 ++++++++++ VueApp/src/CMS/pages/Files.vue | 78 +++++++++++++++++++ VueApp/src/CMS/pages/LeftNavEdit.vue | 4 +- VueApp/src/CMS/pages/LeftNavMenus.vue | 62 +++++++++++++++ .../src/CMS/pages/ManageLinkCollections.vue | 12 +-- VueApp/src/Effort/pages/AuditList.vue | 48 +++++++----- VueApp/src/styles/base.css | 43 ++++++++++ VueApp/src/styles/colors.css | 7 ++ 18 files changed, 446 insertions(+), 58 deletions(-) create mode 100644 VueApp/src/CMS/components/ListCard.vue create mode 100644 VueApp/src/CMS/components/ListCardField.vue diff --git a/VueApp/src/CMS/components/FileFormDialog.vue b/VueApp/src/CMS/components/FileFormDialog.vue index 68bba5c8b..5a9c42e23 100644 --- a/VueApp/src/CMS/components/FileFormDialog.vue +++ b/VueApp/src/CMS/components/FileFormDialog.vue @@ -7,7 +7,7 @@ @hide="resetForm" @keydown.escape="handleClose" > - +
- +
- +
-
diff --git a/VueApp/src/CMS/components/DateRangeFilter.vue b/VueApp/src/CMS/components/DateRangeFilter.vue new file mode 100644 index 000000000..3acf4b6d7 --- /dev/null +++ b/VueApp/src/CMS/components/DateRangeFilter.vue @@ -0,0 +1,33 @@ + + + diff --git a/VueApp/src/CMS/components/RecentActivity.vue b/VueApp/src/CMS/components/RecentActivity.vue index 596166bc1..c25ec97df 100644 --- a/VueApp/src/CMS/components/RecentActivity.vue +++ b/VueApp/src/CMS/components/RecentActivity.vue @@ -40,6 +40,7 @@ @@ -50,7 +51,8 @@ caption :title="formatFullDate(item.modifiedOn)" > - {{ formatTimeAgo(new Date(item.modifiedOn)) }} by {{ item.modifiedBy }} + {{ item.typeLabel }} · {{ formatTimeAgo(new Date(item.modifiedOn)) }} by + {{ item.modifiedBy }} @@ -62,19 +64,25 @@ import { inject, onMounted, ref } from "vue" import { formatTimeAgo } from "@vueuse/core" import { useFetch } from "@/composables/ViperFetch" -import type { CmsContentBlock, CmsFile } from "@/CMS/types/" +import type { CmsContentBlock, CmsFile, CmsLeftNavMenu } from "@/CMS/types/" const MAX_ITEMS = 8 const PER_SOURCE = 5 -const { showBlocks = false, showFiles = false } = defineProps<{ +const { + showBlocks = false, + showFiles = false, + showLeftNavs = false, +} = defineProps<{ showBlocks?: boolean showFiles?: boolean + showLeftNavs?: boolean }>() type ActivityItem = { key: string icon: string + typeLabel: string label: string to: { name: string; params?: Record; query?: Record } modifiedOn: string @@ -101,6 +109,7 @@ async function loadBlocks(): Promise { .map((b) => ({ key: "block-" + b.contentBlockId, icon: "article", + typeLabel: "Content block", label: b.title || b.friendlyName || "(untitled)", to: { name: "CmsContentBlockEdit", params: { id: b.contentBlockId } }, modifiedOn: b.modifiedOn, @@ -120,7 +129,8 @@ async function loadFiles(): Promise { if (!res.success) throw new Error("files") return ((res.result ?? []) as CmsFile[]).map((f) => ({ key: "file-" + f.fileGuid, - icon: "folder", + icon: "description", + typeLabel: "File", label: f.friendlyName, to: { name: "CmsFiles", query: { search: f.friendlyName } }, modifiedOn: f.modifiedOn, @@ -128,8 +138,30 @@ async function loadFiles(): Promise { })) } +async function loadLeftNavs(): Promise { + // The left-nav list endpoint returns all menus unsorted, so sort by modifiedOn here. + const res = await get(apiURL + "cms/left-navs") + if (!res.success) throw new Error("leftNavs") + return [...((res.result ?? []) as CmsLeftNavMenu[])] + .sort((a, b) => new Date(b.modifiedOn).getTime() - new Date(a.modifiedOn).getTime()) + .slice(0, PER_SOURCE) + .map((m) => ({ + key: "leftnav-" + m.leftNavMenuId, + icon: "menu", + typeLabel: "Left-nav menu", + label: m.menuHeaderText || m.friendlyName || "(untitled)", + to: { name: "CmsLeftNavEdit", params: { id: m.leftNavMenuId } }, + modifiedOn: m.modifiedOn, + modifiedBy: m.modifiedBy, + })) +} + async function loadActivity() { - const sources = [...(showBlocks ? [loadBlocks()] : []), ...(showFiles ? [loadFiles()] : [])] + const sources = [ + ...(showBlocks ? [loadBlocks()] : []), + ...(showFiles ? [loadFiles()] : []), + ...(showLeftNavs ? [loadLeftNavs()] : []), + ] const results = await Promise.allSettled(sources) const loaded = results.filter((r) => r.status === "fulfilled").flatMap((r) => r.value) failed.value = sources.length > 0 && results.every((r) => r.status === "rejected") diff --git a/VueApp/src/CMS/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index 1eb9d2204..f0d2a4d70 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -51,6 +51,7 @@
@@ -105,6 +106,11 @@ const sections: Section[] = [ permissions: ["SVMSecure.CMS.ManageContentBlocks"], }, { label: "Add Content Block", to: { name: "CmsContentBlockEdit" } }, + { + label: "Edit History", + to: { name: "CmsContentBlockHistory" }, + permissions: ["SVMSecure.CMS.ManageContentBlocks"], + }, ], }, { @@ -132,7 +138,8 @@ const visibleSections = computed(() => ) const canManageBlocks = computed(() => userStore.userInfo.permissions.includes("SVMSecure.CMS.ManageContentBlocks")) const canManageFiles = computed(() => userStore.userInfo.permissions.includes("SVMSecure.CMS.AllFiles")) -const showRecentActivity = computed(() => canManageBlocks.value || canManageFiles.value) +const canManageNav = computed(() => userStore.userInfo.permissions.includes("SVMSecure.CMS.ManageNavigation")) +const showRecentActivity = computed(() => canManageBlocks.value || canManageFiles.value || canManageNav.value) diff --git a/VueApp/src/CMS/pages/FileAuditLog.vue b/VueApp/src/CMS/pages/FileAuditLog.vue index 8d58f357f..bb740b3a6 100644 --- a/VueApp/src/CMS/pages/FileAuditLog.vue +++ b/VueApp/src/CMS/pages/FileAuditLog.vue @@ -30,30 +30,11 @@ @update:model-value="reload" />
-
- -
-
- -
+
@@ -166,6 +147,7 @@ import BreadcrumbHeading from "@/components/BreadcrumbHeading.vue" import StatusBadge from "@/components/StatusBadge.vue" import ListCard from "@/CMS/components/ListCard.vue" import ListCardField from "@/CMS/components/ListCardField.vue" +import DateRangeFilter from "@/CMS/components/DateRangeFilter.vue" import type { CmsFileAudit } from "@/CMS/types/" const apiURL = inject("apiURL") + "cms/files/audit" diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index c1d55eebc..e07154132 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -53,6 +53,12 @@ const routes = [ meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageContentBlocks"] }, component: () => import("@/CMS/pages/ContentBlocks.vue"), }, + { + path: "/CMS/ManageContentBlocks/History", + name: "CmsContentBlockHistory", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageContentBlocks"] }, + component: () => import("@/CMS/pages/ContentBlockHistory.vue"), + }, { path: "/CMS/ManageContentBlocks/Edit/:id?", name: "CmsContentBlockEdit", diff --git a/VueApp/src/CMS/types/index.ts b/VueApp/src/CMS/types/index.ts index 48cf8746a..844c57bf4 100644 --- a/VueApp/src/CMS/types/index.ts +++ b/VueApp/src/CMS/types/index.ts @@ -64,6 +64,16 @@ type CmsContentHistoryItem = { modifiedBy: string | null } +type CmsContentHistoryDiff = { + content: string + hasComparison: boolean + hasChanges: boolean + oldModifiedOn: string | null + oldModifiedBy: string | null + newModifiedOn: string | null + newModifiedBy: string | null +} + type CmsLeftNavMenu = { leftNavMenuId: number menuHeaderText: string | null @@ -98,6 +108,17 @@ type CmsFileAudit = { clientData: string | null } +type CmsContentHistoryAudit = { + contentHistoryId: number + contentBlockId: number + title: string | null + friendlyName: string | null + page: string | null + modifiedOn: string | null + modifiedBy: string | null + blockDeleted: boolean +} + type LinkCollection = { linkCollectionId: number linkCollection: string @@ -153,6 +174,8 @@ export type { CmsFilePerson, CmsPersonOption, CmsFileAudit, + CmsContentHistoryAudit, + CmsContentHistoryDiff, LinkCollection, LinkCollectionTagCategory, Link, diff --git a/VueApp/src/styles/base.css b/VueApp/src/styles/base.css index c4ef1d712..7ede7daaf 100644 --- a/VueApp/src/styles/base.css +++ b/VueApp/src/styles/base.css @@ -401,6 +401,11 @@ header .q-avatar { max-width: 95vw; } +.dialog-card-lg { + width: 900px; + max-width: 95vw; +} + /* Skip to content link — visible on focus for keyboard users */ .skip-to-content { position: absolute; diff --git a/test/CMS/CmsContentBlockServiceTests.cs b/test/CMS/CmsContentBlockServiceTests.cs index a198dd185..e28268142 100644 --- a/test/CMS/CmsContentBlockServiceTests.cs +++ b/test/CMS/CmsContentBlockServiceTests.cs @@ -26,6 +26,8 @@ public CmsContentBlockServiceTests() .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)); _userHelper = Substitute.For(); _service = new CmsContentBlockService(_context, _sanitizer, _userHelper); @@ -323,4 +325,197 @@ public async Task History_ListAndVersionRetrieval() } #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("> GetContentBlock(int contentBloc [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, null)?.ToList(); + .GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, 1)?.ToList(); if (blocks == null || blocks.Count == 0) { return NotFound(); @@ -103,6 +106,67 @@ public async Task> GetHistoryVersion(int content return version; } + //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 NotFound(); + } + return diff; + } + + //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) + { + return NotFound(); + } + return diff; + } + + //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, + ModifiedBy = modifiedBy, + From = from, + To = to, + Search = search + }; + var page = pagination?.Page ?? 1; + var perPage = pagination?.PerPage ?? 50; + var entries = await _blockService.GetHistoryEntriesAsync(filter, page, perPage, ct); + if (pagination != null) + { + pagination.TotalRecords = await _blockService.GetHistoryEntryCountAsync(filter, ct); + } + return entries; + } + //POST: content [HttpPost] [Permission(Allow = CmsPermissions.CreateContentBlock + "," + CmsPermissions.ManageContentBlocks)] @@ -231,4 +295,9 @@ public class ContentOnlyUpdate public string Content { get; set; } = string.Empty; public DateTime? LastModifiedOn { get; set; } } + + public class DiffAgainstHistoryRequest + { + public string Content { get; set; } = string.Empty; + } } diff --git a/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs index c48e6fec1..efa49df76 100644 --- a/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs +++ b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs @@ -37,4 +37,40 @@ 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/Services/CmsContentBlockService.cs b/web/Areas/CMS/Services/CmsContentBlockService.cs index 06c41c437..8cbe5564a 100644 --- a/web/Areas/CMS/Services/CmsContentBlockService.cs +++ b/web/Areas/CMS/Services/CmsContentBlockService.cs @@ -1,7 +1,9 @@ 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; @@ -39,9 +41,30 @@ Task> GetContentBlocksAsync(string status, string? system, 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, @@ -323,6 +346,159 @@ public async Task> GetHistoryAsync(int contentBl }; } + 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 ?? string.Empty); + var newSafe = _sanitizer.Sanitize(newContent ?? string.Empty); + 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) + { + 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 != null && x.Block.Title.Contains(filter.Search)) + || (x.Block.FriendlyName != null && x.Block.FriendlyName.Contains(filter.Search)) + || (x.Block.Page != null && x.Block.Page.Contains(filter.Search))); + } + return query; + } + public async Task> GetViperSectionPathsAsync(CancellationToken ct = default) { return await _context.ContentBlocks @@ -458,6 +634,13 @@ 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) { return values diff --git a/web/Areas/CMS/Services/CmsFileAuditService.cs b/web/Areas/CMS/Services/CmsFileAuditService.cs index f6de585b5..2c425ab68 100644 --- a/web/Areas/CMS/Services/CmsFileAuditService.cs +++ b/web/Areas/CMS/Services/CmsFileAuditService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Viper.Areas.CMS.Models; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; using Viper.Models.VIPER; using File = Viper.Models.VIPER.File; @@ -117,7 +118,7 @@ private IQueryable BuildQuery(CmsFileAuditFilter filter) if (filter.To != null) { // Treat the To date as inclusive through end of day. - var to = filter.To.Value.Date == filter.To.Value ? filter.To.Value.AddDays(1) : filter.To.Value; + var to = DateRangeHelper.ExclusiveUpperBound(filter.To.Value); query = query.Where(a => a.Timestamp < to); } if (!string.IsNullOrEmpty(filter.Search)) diff --git a/web/Areas/CMS/Services/CmsNavMenu.cs b/web/Areas/CMS/Services/CmsNavMenu.cs index 9f908e7ec..c6ce6849e 100644 --- a/web/Areas/CMS/Services/CmsNavMenu.cs +++ b/web/Areas/CMS/Services/CmsNavMenu.cs @@ -25,7 +25,7 @@ public NavMenu Nav() if (canManageBlocks || canCreateBlocks || canManageFiles || canManageNav) { - nav.Add(new NavMenuItem { MenuItemText = "Home", MenuItemURL = "Home" }); + nav.Add(new NavMenuItem { MenuItemText = "Home", MenuItemURL = "Home", IndentLevel = 1 }); } if (canManageBlocks || canCreateBlocks) @@ -41,6 +41,7 @@ public NavMenu Nav() } 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 }); } } @@ -50,6 +51,7 @@ public NavMenu Nav() 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) diff --git a/web/Classes/Utilities/DateRangeHelper.cs b/web/Classes/Utilities/DateRangeHelper.cs new file mode 100644 index 000000000..265dd2d11 --- /dev/null +++ b/web/Classes/Utilities/DateRangeHelper.cs @@ -0,0 +1,16 @@ +namespace Viper.Classes.Utilities; + +/// +/// Helpers for date-range query filters. +/// +public static class DateRangeHelper +{ + /// + /// Exclusive upper bound for an inclusive "To" date filter. A value with no time component + /// (midnight) is treated as "through the end of that day", so the next midnight is returned; + /// a value that already carries a time of day is returned unchanged. Compare with + /// < against the result. + /// + public static DateTime ExclusiveUpperBound(DateTime to) + => to.Date == to ? to.AddDays(1) : to; +} diff --git a/web/Services/HtmlSanitizerService.cs b/web/Services/HtmlSanitizerService.cs index 7a9de7841..b7876d9e5 100644 --- a/web/Services/HtmlSanitizerService.cs +++ b/web/Services/HtmlSanitizerService.cs @@ -9,12 +9,19 @@ namespace Viper.Services public class HtmlSanitizerService : IHtmlSanitizerService { private readonly HtmlSanitizer _sanitizer; + private readonly HtmlSanitizer _diffSanitizer; public HtmlSanitizerService() { - _sanitizer = new HtmlSanitizer(); + _sanitizer = BuildSanitizer(allowDiffMarkers: false); + _diffSanitizer = BuildSanitizer(allowDiffMarkers: true); + } + + private static HtmlSanitizer BuildSanitizer(bool allowDiffMarkers) + { + var sanitizer = new HtmlSanitizer(); - _sanitizer.AllowedTags.Clear(); + sanitizer.AllowedTags.Clear(); foreach (var tag in new[] { "p", "br", "hr", "strong", "b", "em", "i", "u", "s", "strike", @@ -26,10 +33,20 @@ public HtmlSanitizerService() "sub", "sup", "small", "abbr", "cite", "q" }) { - _sanitizer.AllowedTags.Add(tag); + sanitizer.AllowedTags.Add(tag); + } + + if (allowDiffMarkers) + { + // htmldiff.net wraps changes in / (classes diffins/diffdel/diffmod). + // Allowing just these two tags lets a diff be re-sanitized to balance the library's + // markup and strip anything off-policy while keeping the change markers. The class + // attribute is already allowed below, so the diff* classes survive. + sanitizer.AllowedTags.Add("ins"); + sanitizer.AllowedTags.Add("del"); } - _sanitizer.AllowedAttributes.Clear(); + sanitizer.AllowedAttributes.Clear(); foreach (var attr in new[] { "href", "src", "alt", "title", "class", "id", "name", @@ -38,7 +55,7 @@ public HtmlSanitizerService() "style" }) { - _sanitizer.AllowedAttributes.Add(attr); + sanitizer.AllowedAttributes.Add(attr); } // Curated CSS property allowlist. Mirrors legacy antisamy-cms.xml plus font-size, which @@ -46,7 +63,7 @@ public HtmlSanitizerService() // dangerous CSS value constructs are blocked by Ganss.Xss regardless of this list. // Shorthand properties (text-decoration, list-style) get expanded to longhands by // AngleSharp; both forms must be allowed or the entire declaration is dropped. - _sanitizer.AllowedCssProperties.Clear(); + sanitizer.AllowedCssProperties.Clear(); foreach (var prop in new[] { "color", "font-family", "font-size", "font-style", "font-weight", @@ -60,25 +77,25 @@ public HtmlSanitizerService() "list-style", "list-style-type", "list-style-position", "list-style-image" }) { - _sanitizer.AllowedCssProperties.Add(prop); + sanitizer.AllowedCssProperties.Add(prop); } - _sanitizer.AllowedSchemes.Clear(); - _sanitizer.AllowedSchemes.Add("http"); - _sanitizer.AllowedSchemes.Add("https"); - _sanitizer.AllowedSchemes.Add("mailto"); - _sanitizer.AllowedSchemes.Add("tel"); + sanitizer.AllowedSchemes.Clear(); + sanitizer.AllowedSchemes.Add("http"); + sanitizer.AllowedSchemes.Add("https"); + sanitizer.AllowedSchemes.Add("mailto"); + sanitizer.AllowedSchemes.Add("tel"); // Block all CSS at-rules (@import, @font-face, etc.). At-rules are not valid inside // an inline style attribute, but clearing the set is defense in depth against parser // edge cases. - _sanitizer.AllowedAtRules.Clear(); + sanitizer.AllowedAtRules.Clear(); // Block data: URIs in any URL (image XSS vector) on top of the scheme allowlist. // Also lock to relative URLs only — matches the legacy antisamy-cms.xml // onsiteURL regex and prevents editor-authored content from embedding third-party // tracking beacons or any absolute-URL image (same-origin absolutes included). - _sanitizer.FilterUrl += (sender, args) => + sanitizer.FilterUrl += (sender, args) => { if (args.OriginalUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { @@ -92,6 +109,8 @@ public HtmlSanitizerService() args.SanitizedUrl = null; } }; + + return sanitizer; } private static bool IsOffSiteUrl(string url) @@ -103,5 +122,7 @@ private static bool IsOffSiteUrl(string url) } public string Sanitize(string html) => _sanitizer.Sanitize(html); + + public string SanitizeDiff(string html) => _diffSanitizer.Sanitize(html); } } diff --git a/web/Services/IHtmlSanitizerService.cs b/web/Services/IHtmlSanitizerService.cs index ab451e0a3..0225b525b 100644 --- a/web/Services/IHtmlSanitizerService.cs +++ b/web/Services/IHtmlSanitizerService.cs @@ -3,5 +3,12 @@ namespace Viper.Services public interface IHtmlSanitizerService { string Sanitize(string html); + + /// + /// Sanitize generated diff markup. Same policy as but additionally + /// permits the <ins>/<del> markers htmldiff.net wraps changes in, so a diff can be + /// re-parsed (balancing the library's malformed tags) and stripped to policy before render. + /// + string SanitizeDiff(string html); } } diff --git a/web/Viper.csproj b/web/Viper.csproj index 4ccb2a6ac..eafc1ba5a 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -54,6 +54,7 @@ + From 28ac302670a3f29fd6dc29fc66ee38b2f450f2e9 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 16 Jun 2026 09:48:18 -0700 Subject: [PATCH 3/6] VPR-59 test(cms): add frontend and backend test coverage - Frontend: Vitest/@vue/test-utils suites for CMS utils, components, dialogs, selectors, list pages, and the content-history and diff views - Backend: controller tests for files, content, left-nav, link collections, and user photos, plus content-block permission filtering - Add SanitizeDiff cases proving the diff sanitizer strips XSS while keeping the ins/del change markers --- .../__tests__/content-block-edit-diff.test.ts | 166 +++++++ .../__tests__/content-block-history.test.ts | 229 ++++++++++ .../src/CMS/__tests__/content-blocks.test.ts | 115 +++++ .../CMS/__tests__/content-diff-dialog.test.ts | 66 +++ .../CMS/__tests__/date-range-filter.test.ts | 52 +++ .../CMS/__tests__/file-form-dialog.test.ts | 197 ++++++++ VueApp/src/CMS/__tests__/files.test.ts | 136 ++++++ .../__tests__/left-nav-menu-dialog.test.ts | 100 ++++ .../src/CMS/__tests__/left-nav-menus.test.ts | 93 ++++ .../CMS/__tests__/link-collections.test.ts | 206 +++++++++ VueApp/src/CMS/__tests__/link.test.ts | 136 ++++++ .../src/CMS/__tests__/modified-stamp.test.ts | 60 +++ .../CMS/__tests__/permission-selector.test.ts | 86 ++++ .../src/CMS/__tests__/person-selector.test.ts | 98 ++++ .../src/CMS/__tests__/recent-activity.test.ts | 146 ++++++ VueApp/src/CMS/__tests__/test-utils.ts | 82 ++++ VueApp/src/CMS/__tests__/url.test.ts | 107 +++++ test/CMS/CMSContentControllerTests.cs | 426 ++++++++++++++++++ test/CMS/CMSContentPermissionTests.cs | 178 ++++++++ test/CMS/CMSFilesControllerTests.cs | 380 ++++++++++++++++ test/CMS/CMSLeftNavControllerTests.cs | 185 ++++++++ test/CMS/CMSLinkCollectionControllerTests.cs | 287 ++++++++++++ test/CMS/CMSLinkCollectionLinksTests.cs | 369 +++++++++++++++ test/CMS/CMSUserPhotoControllerTests.cs | 82 ++++ .../Classes/Utilities/DateRangeHelperTests.cs | 34 ++ test/Services/HtmlSanitizerServiceTests.cs | 47 ++ 26 files changed, 4063 insertions(+) create mode 100644 VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-block-history.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-blocks.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-diff-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/date-range-filter.test.ts create mode 100644 VueApp/src/CMS/__tests__/file-form-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/files.test.ts create mode 100644 VueApp/src/CMS/__tests__/left-nav-menu-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/left-nav-menus.test.ts create mode 100644 VueApp/src/CMS/__tests__/link-collections.test.ts create mode 100644 VueApp/src/CMS/__tests__/link.test.ts create mode 100644 VueApp/src/CMS/__tests__/modified-stamp.test.ts create mode 100644 VueApp/src/CMS/__tests__/permission-selector.test.ts create mode 100644 VueApp/src/CMS/__tests__/person-selector.test.ts create mode 100644 VueApp/src/CMS/__tests__/recent-activity.test.ts create mode 100644 VueApp/src/CMS/__tests__/test-utils.ts create mode 100644 VueApp/src/CMS/__tests__/url.test.ts create mode 100644 test/CMS/CMSContentControllerTests.cs create mode 100644 test/CMS/CMSContentPermissionTests.cs create mode 100644 test/CMS/CMSFilesControllerTests.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/CMSUserPhotoControllerTests.cs create mode 100644 test/Classes/Utilities/DateRangeHelperTests.cs diff --git a/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts b/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts new file mode 100644 index 000000000..6d6418450 --- /dev/null +++ b/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts @@ -0,0 +1,166 @@ +import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue" +import { mountCms, flushPromises, createTestRouter } from "./test-utils" + +/** + * Diff additions to ContentBlockEdit: selecting a version only sets selectedHistory (it no longer + * auto-loads), the "Diff vs current" / "Load into editor" buttons are disabled until a version is + * selected, and diffAgainstCurrent POSTs the editor's CURRENT content to the diff endpoint, opens + * ContentDiffDialog with the returned HTML, and notifies + closes on failure. Mock ViperFetch. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + createUrlSearchParams: (obj: Record) => { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + params.append(k, v.toString()) + } + } + return params + }, + }), +})) + +const BLOCK = { + contentBlockId: 7, + content: "

current editor content

", + title: "Welcome", + system: "Viper", + application: null, + page: null, + viperSectionPath: null, + blockOrder: 1, + friendlyName: "welcome", + allowPublicAccess: false, + modifiedOn: "2024-03-01T12:00:00", + modifiedBy: "editor", + deletedOn: null, + permissions: [], + files: [], +} + +const HISTORY = [ + { contentHistoryId: 91, modifiedOn: "2024-02-01T10:00:00", modifiedBy: "bob" }, + { contentHistoryId: 92, modifiedOn: "2024-01-01T09:00:00", modifiedBy: "amy" }, +] + +function routeGet() { + mockGet.mockReset() + mockPost.mockReset() + mockGet.mockImplementation((...args: unknown[]) => { + const url = args[0] as string + if (url.includes("/section-paths")) { + return Promise.resolve({ success: true, result: [] }) + } + if (url.includes("/history")) { + return Promise.resolve({ success: true, result: HISTORY }) + } + // The single-block load (.../content/7). + return Promise.resolve({ success: true, result: { ...BLOCK } }) + }) +} + +// QEditor relies on document.execCommand and rich-text DOM that happy-dom lacks; stub it with a +// minimal v-model textarea so block.content still binds without exercising the real editor. +const qEditorStub = { + name: "QEditor", + props: ["modelValue"], + emits: ["update:modelValue"], + methods: { + getContentEl() { + return null + }, + }, + template: `