From c6387a32b43a5b03e8722643bab6e3fc52050459 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 3 Jul 2026 01:21:50 -0700 Subject: [PATCH] VPR-59 feat(cms): delegated block editing via per-block edit permissions Admins attach RAPS permissions to a content block as its EDIT permissions; holders of any of them can edit that block without SVMSecure.CMS.ManageContentBlocks. - New ContentBlockToEditPermission entity/table (schema applied manually per environment; DDL in the PR description). An empty edit list means manager-only: delegation is explicit, deliberately not the view list "empty means all SVMSecure" rule - CanEditAsync (manager OR edit-permission intersection, fail-closed, single-resolve HashSet) gates block load, history, diff, and the content-only PATCH, which now also carries attachment deltas; the delegated DTO has no settings fields, so field whitelisting is server-side by construction - Content-scoped file endpoints (attachable search capped at 25 with a minimal DTO, per-block check-name/upload, rollback-only delete constrained to uploader+folder+not-attached-elsewhere) give the editor one code path for managers and delegates alike - Editor renders a capability mode: read-only settings summary for delegates, full editor plus a new "Edit access" selector for managers; the hub gains a "Blocks you can edit" card; the editor route relies on server enforcement - Delegates can view and load version history (restoring is a content save); soft-deleted blocks are not delegate-editable; the anonymous fn endpoint discloses nothing new - 48 new tests (authorization matrix, field scoping, attachment deltas, rollback constraints, capability-mode rendering, hub card) --- VueApp/src/CMS/__tests__/cms-home.test.ts | 76 +++ .../content-block-edit-delegated.test.ts | 206 +++++++ .../__tests__/content-block-edit-save.test.ts | 33 +- .../CMS/__tests__/inline-file-upload.test.ts | 72 ++- .../src/CMS/components/InlineFileUpload.vue | 60 +- .../src/CMS/components/PermissionSelector.vue | 5 +- VueApp/src/CMS/pages/CmsHome.vue | 59 +- VueApp/src/CMS/pages/ContentBlockEdit.vue | 347 ++++++++---- VueApp/src/CMS/router/routes.ts | 15 +- VueApp/src/CMS/types/index.ts | 25 + test/CMS/CMSContentControllerTests.cs | 246 +++++++- test/CMS/CmsContentBlockServiceTests.cs | 524 +++++++++++++++++- .../CMS/Controllers/CMSContentController.cs | 239 +++++++- web/Areas/CMS/Models/CMSBlockAddEdit.cs | 6 + web/Areas/CMS/Models/CmsContentBlockMapper.cs | 2 + web/Areas/CMS/Models/DTOs/ContentBlockDto.cs | 13 + .../CMS/Services/CmsContentBlockService.cs | 341 +++++++++++- web/Classes/SQLContext/VIPERContext.cs | 21 + web/Models/VIPER/ContentBlock.cs | 2 + .../VIPER/ContentBlockToEditPermission.cs | 12 + 20 files changed, 2118 insertions(+), 186 deletions(-) create mode 100644 VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts create mode 100644 web/Models/VIPER/ContentBlockToEditPermission.cs diff --git a/VueApp/src/CMS/__tests__/cms-home.test.ts b/VueApp/src/CMS/__tests__/cms-home.test.ts index f892de107..1783f3467 100644 --- a/VueApp/src/CMS/__tests__/cms-home.test.ts +++ b/VueApp/src/CMS/__tests__/cms-home.test.ts @@ -138,3 +138,79 @@ describe("cmsHome.vue - permission-gated sections", () => { expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0) }) }) + +/** + * Delegated editors (no ManageContentBlocks) get a "Blocks you can edit" card listing GET /editable + * results, linking each to its editor. It shows only for non-managers with a non-empty list, and it + * suppresses the no-access banner. Managers use the normal Content Blocks card, so they never fetch + * or render this card. These specs run last because they replace the shared get() implementation. + */ +describe("cmsHome.vue - delegated editable blocks card", () => { + const EDITABLE = [ + { + contentBlockId: 11, + title: "Alpha", + friendlyName: "alpha", + viperSectionPath: "/a", + page: null, + modifiedOn: "2024-01-01T00:00:00", + modifiedBy: "u", + }, + { + contentBlockId: 12, + title: null, + friendlyName: "beta", + viperSectionPath: "/b", + page: null, + modifiedOn: "2024-01-02T00:00:00", + modifiedBy: "u", + }, + ] + + function routeEditable(items: unknown[]) { + getMock.mockImplementation((...args: unknown[]) => { + const url = args[0] as unknown as string + if (url.includes("editable")) { + return Promise.resolve({ success: true, result: items }) + } + return Promise.resolve({ success: true, result: [] }) + }) + } + + function editLinks(wrapper: Awaited>) { + return wrapper + .findAllComponents({ name: "QBtn" }) + .filter((b) => (b.props("to") as { name?: string } | undefined)?.name === "CmsContentBlockEdit") + } + + it("lists editable blocks with edit links and suppresses the no-access banner for a non-manager", async () => { + routeEditable(EDITABLE) + const wrapper = await mountHome(["SVMSecure.CMS"]) + await flushPromises() + await flushPromises() + + expect(wrapper.text()).toContain("Blocks you can edit") + expect(wrapper.text()).not.toContain("does not have access to any CMS tools") + + // Falls back to the friendly name when a block has no title (block 12 -> "beta"). + expect(editLinks(wrapper).map((b) => b.props("label"))).toStrictEqual(["Alpha", "beta"]) + expect(editLinks(wrapper)[0]!.props("to")).toStrictEqual({ + name: "CmsContentBlockEdit", + params: { id: 11 }, + }) + }) + + it("does not fetch or show the editable card for a manager", async () => { + // The shared get() mock accumulates calls across tests; clear it so this assertion only + // sees this manager mount's requests. + getMock.mockClear() + routeEditable(EDITABLE) + const wrapper = await mountHome() // full admin + await flushPromises() + + expect(wrapper.text()).not.toContain("Blocks you can edit") + // Managers still get the normal tool card, and never hit the editable endpoint. + expect(wrapper.text()).toContain("Content Blocks") + expect(getMock.mock.calls.map((c) => c[0] as unknown as string).some((u) => u.includes("editable"))).toBe(false) + }) +}) diff --git a/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts b/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts new file mode 100644 index 000000000..3d8cf3738 --- /dev/null +++ b/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts @@ -0,0 +1,206 @@ +import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue" +import { useUserStore } from "@/store/UserStore" +import { mountCms, flushPromises, createTestRouter } from "./test-utils" + +/** + * Delegated editing: a user WITHOUT ManageContentBlocks/CreateContentBlock who reaches an existing + * block edits only its content and files. The settings sidebar collapses to a read-only summary + * (no inputs, no permission selectors, no public-access toggle), the content editor and attached + * files stay functional, and Save issues the content-only PATCH (with the same lastModifiedOn + * concurrency stamp + 409 conflict dialog as the full save). Mock ViperFetch; the inline uploader + * is stubbed with a controllable commit(). + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockPatch = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = 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), + patch: (...args: unknown[]) => mockPatch(...args), + del: (...args: unknown[]) => mockDel(...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: "

hello

", + title: "Welcome", + system: "Viper", + application: null, + page: "home", + viperSectionPath: "/apps", + blockOrder: 2, + friendlyName: "welcome", + allowPublicAccess: false, + modifiedOn: "2024-03-01T12:00:00", + modifiedBy: "editor", + deletedOn: null, + permissions: ["SVMSecure.CMS"], + editPermissions: ["SVMSecure.CMS.Delegate"], + files: [{ fileGuid: "f1", friendlyName: "a.pdf", url: "/files/a.pdf" }], +} + +function routeGet() { + mockGet.mockImplementation((...args: unknown[]) => { + const url = args[0] as string + if (url.includes("/history")) { + return Promise.resolve({ success: true, result: [] }) + } + return Promise.resolve({ success: true, result: structuredClone(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: `