Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5d068e4
VPR-59 fix(cms): preserve file bytes when save fails after overwrite
rlorenzo Jun 29, 2026
2418dfb
VPR-59 fix(cms): reject protocol-relative URLs in link validation
rlorenzo Jun 29, 2026
9f3fbdf
VPR-59 feat(cms): open the Home hub to granular-permission users
rlorenzo Jun 29, 2026
8ddec7c
VPR-59 refactor(cms): align badges, tokens, and a11y across CMS pages
rlorenzo Jun 29, 2026
326a36c
VPR-59 feat(cms): inline block file uploads and trash auto-purge
rlorenzo Jul 2, 2026
c887ab1
VPR-59 fix(cms): make file path handling OS-independent
rlorenzo Jul 2, 2026
5e568bc
VPR-59 chore(cms): resolve ReSharper and CodeQL findings
rlorenzo Jul 2, 2026
c940439
VPR-59 refactor(cms): cut Vue page complexity and duplication
rlorenzo Jul 2, 2026
cd56f11
VPR-59 test(cms): make import webroot fixtures OS-independent
rlorenzo Jul 2, 2026
9104370
VPR-59 fix(cms): stop anonymous content endpoint leaking file keys
rlorenzo Jul 2, 2026
fbd500b
VPR-59 fix(cms): harden file downloads against stored XSS
rlorenzo Jul 2, 2026
aacc127
VPR-59 fix(cms): tighten left-nav validation and save integrity
rlorenzo Jul 2, 2026
b9746a8
VPR-59 fix(cms): small hardening and DI cleanups
rlorenzo Jul 2, 2026
28d86d8
VPR-59 test(cms): expand frontend coverage for files and editing
rlorenzo Jul 2, 2026
0ebcab3
VPR-59 fix(cms): align validation namespace with file location
rlorenzo Jul 2, 2026
011642d
VPR-59 feat(cms): server-side paging for the content block list
rlorenzo Jul 2, 2026
df63a61
VPR-59 refactor(cms): dialog guard, filter consistency, token cleanup
rlorenzo Jul 2, 2026
3de98c1
VPR-59 fix(cms): surface truly recent blocks in the activity rail
rlorenzo Jul 2, 2026
198703f
VPR-59 feat(cms): mirror nav groups on the hub and surface activity
rlorenzo Jul 2, 2026
b0f652b
VPR-59 refactor(cms): extract ActivityRow to satisfy the fallow gate
rlorenzo Jul 2, 2026
236a7ed
VPR-59 fix(cms): apply full-branch review fixes across API and UI
rlorenzo Jul 2, 2026
d3272cb
VPR-59 feat(cms): detect concurrent file edits with 409 conflicts
rlorenzo Jul 2, 2026
386d63d
VPR-59 fix(cms): restore legacy photo lookups and conditional caching
rlorenzo Jul 2, 2026
8d0fadc
VPR-59 fix(cms): address browser smoke-test findings
rlorenzo Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions VueApp/src/CMS/__tests__/cms-home.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | null | undefined>) => {
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: "<div class='recent-activity-stub' />",
}

// 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<ReturnType<typeof mountHome>>): 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)
})
})
Loading