diff --git a/VueApp/src/CAHFS/router/index.ts b/VueApp/src/CAHFS/router/index.ts index 1ee7424d1..bf695865a 100644 --- a/VueApp/src/CAHFS/router/index.ts +++ b/VueApp/src/CAHFS/router/index.ts @@ -1,4 +1,4 @@ -import { createSpaRouter } from "@/shared/createSpaRouter" +import { createSpaRouter } from "@/shared/create-spa-router" import { routes } from "./routes" import { useRequireLogin } from "@/composables/RequireLogin" import { checkHasOnePermission } from "@/composables/CheckPagePermission" diff --git a/VueApp/src/CMS/__tests__/cms-home.test.ts b/VueApp/src/CMS/__tests__/cms-home.test.ts new file mode 100644 index 000000000..f892de107 --- /dev/null +++ b/VueApp/src/CMS/__tests__/cms-home.test.ts @@ -0,0 +1,140 @@ +import CmsHome from "@/CMS/pages/CmsHome.vue" +import { useUserStore } from "@/store/UserStore" +import { mountCms, flushPromises } from "./test-utils" + +// The hub checks the trash for files nearing the purge cutoff (file managers only). +const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(() => + Promise.resolve({ success: true, result: [] }), +) +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: getMock, + 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 + }, + }), +})) + +function trashedFile(friendlyName: string, purgeOn: string) { + return { fileGuid: `g-${friendlyName}`, friendlyName, purgeOn, deletedOn: "2024-01-01T00:00:00" } +} + +function daysFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() +} + +/** + * CmsHome is the permission-gated hub: each tool card (and each action inside a card) only shows + * when the user holds one of its permissions, the recent-activity rail mirrors the manage + * permissions, and users with no CMS access get an explanatory banner instead. RecentActivity is + * stubbed (it fetches its own data); these tests only assert which props gate its panels. + */ + +const recentActivityStub = { + name: "RecentActivity", + props: ["showBlocks", "showFiles", "showLeftNavs"], + template: "
", +} + +// MountCms seeds the full CMS admin permission set; narrower personas are applied afterwards +// through the same user store the page reads reactively. +async function mountHome(permissions?: string[]) { + const wrapper = mountCms(CmsHome, { global: { stubs: { RecentActivity: recentActivityStub } } }) + if (permissions) { + useUserStore().setPermissions(permissions) + await flushPromises() + } + await flushPromises() + return wrapper +} + +function actionLabels(wrapper: Awaited>): string[] { + return wrapper.findAllComponents({ name: "QBtn" }).map((b) => b.props("label") as string) +} + +describe("cmsHome.vue - permission-gated sections", () => { + it("shows every tool card and the full activity rail for a CMS admin", async () => { + const wrapper = await mountHome() + + // One box per left-nav group, in the nav's order (Link Collections is an action + // inside Content Blocks, mirroring CmsNavMenu.cs). + // h2 text includes the leading icon ligature name in happy-dom, hence stringContaining. + expect(wrapper.findAll("h2").map((h) => h.text())).toStrictEqual([ + expect.stringContaining("Content Blocks"), + expect.stringContaining("Files"), + expect.stringContaining("Left Navigation"), + ]) + expect(actionLabels(wrapper)).toContain("Manage Link Collections") + const rail = wrapper.findComponent({ name: "RecentActivity" }) + expect(rail.props("showBlocks")).toBeTruthy() + expect(rail.props("showFiles")).toBeTruthy() + expect(rail.props("showLeftNavs")).toBeTruthy() + }) + + it("shows a create-only user just the Content Blocks card with only the Add action", async () => { + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.CreateContentBlock"]) + + expect(wrapper.text()).toContain("Content Blocks") + expect(wrapper.text()).not.toContain("Link Collections") + expect(wrapper.text()).not.toContain("Left Navigation") + // Manage/History require ManageContentBlocks even inside a visible section. + expect(actionLabels(wrapper)).toStrictEqual(["Add Content Block"]) + // No manage permission at all: the activity rail is hidden entirely. + expect(wrapper.findComponent({ name: "RecentActivity" }).exists()).toBeFalsy() + }) + + it("shows a files-only user the Files card, including the pre-filtered Trash deep-link", async () => { + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.AllFiles"]) + + expect(actionLabels(wrapper)).toStrictEqual(["Manage Files", "Add File", "Audit Trail", "Trash"]) + const addFile = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Add File")! + expect(addFile.props("to")).toStrictEqual({ name: "CmsFiles", query: { upload: "1" } }) + const trash = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Trash")! + expect(trash.props("to")).toStrictEqual({ name: "CmsFiles", query: { status: "deleted" } }) + + const rail = wrapper.findComponent({ name: "RecentActivity" }) + expect(rail.props("showFiles")).toBeTruthy() + expect(rail.props("showBlocks")).toBeFalsy() + expect(rail.props("showLeftNavs")).toBeFalsy() + }) + + it("warns file managers when trashed files purge within a week, linking to the Trash", async () => { + getMock.mockResolvedValueOnce({ + success: true, + result: [ + trashedFile("soon.pdf", daysFromNow(2)), + trashedFile("soon2.pdf", daysFromNow(6)), + trashedFile("later.pdf", daysFromNow(20)), + ], + }) + const wrapper = await mountHome() + + expect(wrapper.text()).toContain("2 trashed files will be permanently deleted within 7 days.") + expect(wrapper.find("a[href*='status=deleted']").exists()).toBeTruthy() + const trashFetch = getMock.mock.calls + .map((c) => c[0] as unknown as string) + .find((u) => u.includes("status=deleted"))! + expect(trashFetch).toContain("sortBy=deletedOn") + expect(trashFetch).toContain("descending=false") + }) + + it("shows no purge warning when nothing in the trash purges soon", async () => { + getMock.mockResolvedValueOnce({ success: true, result: [trashedFile("later.pdf", daysFromNow(20))] }) + const wrapper = await mountHome() + + expect(wrapper.text()).not.toContain("permanently deleted within") + }) + + it("tells a user with no CMS tool permissions that they have no access", async () => { + const wrapper = await mountHome(["SVMSecure.CMS"]) + + expect(wrapper.text()).toContain("Your account does not have access to any CMS tools.") + expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0) + }) +}) 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: `