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..c43eddfec --- /dev/null +++ b/VueApp/src/CMS/__tests__/cms-home.test.ts @@ -0,0 +1,223 @@ +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). +// Default GET: empty success. routeEditable() overrides this per test; the beforeEach below restores +// it so the override never leaks into a later test (which previously required this suite to run last). +const defaultGetImpl = (): Promise<{ success: boolean; result: unknown[] }> => + Promise.resolve({ success: true, result: [] }) +const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(defaultGetImpl) +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 + }, + }), +})) + +beforeEach(() => { + getMock.mockImplementation(defaultGetImpl) +}) + +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) + }) +}) + +/** + * 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. routeEditable() overrides the shared get() mock within these specs; the + * beforeEach above restores it, so their ordering relative to the other specs no longer matters. + */ +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"))).toBeFalsy() + }) +}) 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: `