|
| 1 | +import CmsHome from "@/CMS/pages/CmsHome.vue" |
| 2 | +import { useUserStore } from "@/store/UserStore" |
| 3 | +import { mountCms, flushPromises } from "./test-utils" |
| 4 | + |
| 5 | +// The hub checks the trash for files nearing the purge cutoff (file managers only). |
| 6 | +const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(() => |
| 7 | + Promise.resolve({ success: true, result: [] }), |
| 8 | +) |
| 9 | +vi.mock("@/composables/ViperFetch", () => ({ |
| 10 | + useFetch: () => ({ |
| 11 | + get: getMock, |
| 12 | + createUrlSearchParams: (obj: Record<string, string | number | null | undefined>) => { |
| 13 | + const params = new URLSearchParams() |
| 14 | + for (const [k, v] of Object.entries(obj)) { |
| 15 | + if (v !== null && v !== undefined) { |
| 16 | + params.append(k, v.toString()) |
| 17 | + } |
| 18 | + } |
| 19 | + return params |
| 20 | + }, |
| 21 | + }), |
| 22 | +})) |
| 23 | + |
| 24 | +function trashedFile(friendlyName: string, purgeOn: string) { |
| 25 | + return { fileGuid: `g-${friendlyName}`, friendlyName, purgeOn, deletedOn: "2024-01-01T00:00:00" } |
| 26 | +} |
| 27 | + |
| 28 | +function daysFromNow(days: number): string { |
| 29 | + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * CmsHome is the permission-gated hub: each tool card (and each action inside a card) only shows |
| 34 | + * when the user holds one of its permissions, the recent-activity rail mirrors the manage |
| 35 | + * permissions, and users with no CMS access get an explanatory banner instead. RecentActivity is |
| 36 | + * stubbed (it fetches its own data); these tests only assert which props gate its panels. |
| 37 | + */ |
| 38 | + |
| 39 | +const recentActivityStub = { |
| 40 | + name: "RecentActivity", |
| 41 | + props: ["showBlocks", "showFiles", "showLeftNavs"], |
| 42 | + template: "<div class='recent-activity-stub' />", |
| 43 | +} |
| 44 | + |
| 45 | +// MountCms seeds the full CMS admin permission set; narrower personas are applied afterwards |
| 46 | +// through the same user store the page reads reactively. |
| 47 | +async function mountHome(permissions?: string[]) { |
| 48 | + const wrapper = mountCms(CmsHome, { global: { stubs: { RecentActivity: recentActivityStub } } }) |
| 49 | + if (permissions) { |
| 50 | + useUserStore().setPermissions(permissions) |
| 51 | + await flushPromises() |
| 52 | + } |
| 53 | + await flushPromises() |
| 54 | + return wrapper |
| 55 | +} |
| 56 | + |
| 57 | +function actionLabels(wrapper: Awaited<ReturnType<typeof mountHome>>): string[] { |
| 58 | + return wrapper.findAllComponents({ name: "QBtn" }).map((b) => b.props("label") as string) |
| 59 | +} |
| 60 | + |
| 61 | +describe("cmsHome.vue - permission-gated sections", () => { |
| 62 | + it("shows every tool card and the full activity rail for a CMS admin", async () => { |
| 63 | + const wrapper = await mountHome() |
| 64 | + |
| 65 | + // One box per left-nav group, in the nav's order (Link Collections is an action |
| 66 | + // inside Content Blocks, mirroring CmsNavMenu.cs). |
| 67 | + // h2 text includes the leading icon ligature name in happy-dom, hence stringContaining. |
| 68 | + expect(wrapper.findAll("h2").map((h) => h.text())).toStrictEqual([ |
| 69 | + expect.stringContaining("Content Blocks"), |
| 70 | + expect.stringContaining("Files"), |
| 71 | + expect.stringContaining("Left Navigation"), |
| 72 | + ]) |
| 73 | + expect(actionLabels(wrapper)).toContain("Manage Link Collections") |
| 74 | + const rail = wrapper.findComponent({ name: "RecentActivity" }) |
| 75 | + expect(rail.props("showBlocks")).toBeTruthy() |
| 76 | + expect(rail.props("showFiles")).toBeTruthy() |
| 77 | + expect(rail.props("showLeftNavs")).toBeTruthy() |
| 78 | + }) |
| 79 | + |
| 80 | + it("shows a create-only user just the Content Blocks card with only the Add action", async () => { |
| 81 | + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.CreateContentBlock"]) |
| 82 | + |
| 83 | + expect(wrapper.text()).toContain("Content Blocks") |
| 84 | + expect(wrapper.text()).not.toContain("Link Collections") |
| 85 | + expect(wrapper.text()).not.toContain("Left Navigation") |
| 86 | + // Manage/History require ManageContentBlocks even inside a visible section. |
| 87 | + expect(actionLabels(wrapper)).toStrictEqual(["Add Content Block"]) |
| 88 | + // No manage permission at all: the activity rail is hidden entirely. |
| 89 | + expect(wrapper.findComponent({ name: "RecentActivity" }).exists()).toBeFalsy() |
| 90 | + }) |
| 91 | + |
| 92 | + it("shows a files-only user the Files card, including the pre-filtered Trash deep-link", async () => { |
| 93 | + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.AllFiles"]) |
| 94 | + |
| 95 | + expect(actionLabels(wrapper)).toStrictEqual(["Manage Files", "Add File", "Audit Trail", "Trash"]) |
| 96 | + const addFile = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Add File")! |
| 97 | + expect(addFile.props("to")).toStrictEqual({ name: "CmsFiles", query: { upload: "1" } }) |
| 98 | + const trash = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Trash")! |
| 99 | + expect(trash.props("to")).toStrictEqual({ name: "CmsFiles", query: { status: "deleted" } }) |
| 100 | + |
| 101 | + const rail = wrapper.findComponent({ name: "RecentActivity" }) |
| 102 | + expect(rail.props("showFiles")).toBeTruthy() |
| 103 | + expect(rail.props("showBlocks")).toBeFalsy() |
| 104 | + expect(rail.props("showLeftNavs")).toBeFalsy() |
| 105 | + }) |
| 106 | + |
| 107 | + it("warns file managers when trashed files purge within a week, linking to the Trash", async () => { |
| 108 | + getMock.mockResolvedValueOnce({ |
| 109 | + success: true, |
| 110 | + result: [ |
| 111 | + trashedFile("soon.pdf", daysFromNow(2)), |
| 112 | + trashedFile("soon2.pdf", daysFromNow(6)), |
| 113 | + trashedFile("later.pdf", daysFromNow(20)), |
| 114 | + ], |
| 115 | + }) |
| 116 | + const wrapper = await mountHome() |
| 117 | + |
| 118 | + expect(wrapper.text()).toContain("2 trashed files will be permanently deleted within 7 days.") |
| 119 | + expect(wrapper.find("a[href*='status=deleted']").exists()).toBeTruthy() |
| 120 | + const trashFetch = getMock.mock.calls |
| 121 | + .map((c) => c[0] as unknown as string) |
| 122 | + .find((u) => u.includes("status=deleted"))! |
| 123 | + expect(trashFetch).toContain("sortBy=deletedOn") |
| 124 | + expect(trashFetch).toContain("descending=false") |
| 125 | + }) |
| 126 | + |
| 127 | + it("shows no purge warning when nothing in the trash purges soon", async () => { |
| 128 | + getMock.mockResolvedValueOnce({ success: true, result: [trashedFile("later.pdf", daysFromNow(20))] }) |
| 129 | + const wrapper = await mountHome() |
| 130 | + |
| 131 | + expect(wrapper.text()).not.toContain("permanently deleted within") |
| 132 | + }) |
| 133 | + |
| 134 | + it("tells a user with no CMS tool permissions that they have no access", async () => { |
| 135 | + const wrapper = await mountHome(["SVMSecure.CMS"]) |
| 136 | + |
| 137 | + expect(wrapper.text()).toContain("Your account does not have access to any CMS tools.") |
| 138 | + expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0) |
| 139 | + }) |
| 140 | +}) |
0 commit comments