Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8c91ae3
VPR-59 feat(cms): add file management API
rlorenzo Jun 11, 2026
b0962a0
VPR-59 feat(cms): add file management UI
rlorenzo Jun 11, 2026
5d2a10e
VPR-59 feat(cms): add content block management with version history
rlorenzo Jun 11, 2026
6b8232a
VPR-59 feat(cms): add left navigation menu management
rlorenzo Jun 11, 2026
841559c
VPR-59 feat(cms): add file import and bulk encryption tools
rlorenzo Jun 11, 2026
689a959
VPR-59 feat(cms): add user photo endpoints
rlorenzo Jun 11, 2026
8c31439
VPR-59 feat(cms): rate limit file and ZIP downloads
rlorenzo Jun 12, 2026
c1aaae9
VPR-59 feat(cms): add CMS left nav and redesign hub with recent activity
rlorenzo Jun 12, 2026
7a16787
VPR-59 fix(cms): close a11y gaps and align labels across CMS pages
rlorenzo Jun 12, 2026
6245e29
VPR-59 feat(cms): VIPER2 download links, shareable filters, folder fixes
rlorenzo Jun 12, 2026
da284d6
VPR-59 fix(cms): remove copy-link button from file actions
rlorenzo Jun 12, 2026
170785d
VPR-59 fix(cms): point Old URL links at the VIPER 1 host
rlorenzo Jun 12, 2026
57f9121
VPR-59 feat(cms): resolve upload name conflicts and preview imports
rlorenzo Jun 13, 2026
997136c
VPR-59 feat(cms): standardize form UX, navigation, and list filters
rlorenzo Jun 13, 2026
67e83ef
VPR-59 feat(cms): add mobile card mode to list tables and polish a11y
rlorenzo Jun 16, 2026
6a8f4c2
VPR-59 feat(cms): add content block edit history with version diffs
rlorenzo Jun 16, 2026
78b7c02
VPR-59 test(cms): add frontend and backend test coverage
rlorenzo Jun 16, 2026
b834620
VPR-59 fix(cms): use routable action for link collection 201 Location
rlorenzo Jun 16, 2026
a63700e
VPR-59 feat(cms): unify reorder UX for left-nav items and link collec…
rlorenzo Jun 17, 2026
41be722
VPR-59 fix(cms): correct restore, collection delete, and tag errors
rlorenzo Jun 18, 2026
c74b954
VPR-59 fix(cms): preserve file bytes when save fails after overwrite
rlorenzo Jun 29, 2026
755f367
VPR-59 fix(cms): reject protocol-relative URLs in link validation
rlorenzo Jun 29, 2026
7a0081d
VPR-59 feat(cms): open the Home hub to granular-permission users
rlorenzo Jun 29, 2026
55c130e
VPR-59 refactor(cms): align badges, tokens, and a11y across CMS pages
rlorenzo Jun 29, 2026
d1902fb
VPR-59 feat(cms): inline block file uploads and trash auto-purge
rlorenzo Jul 2, 2026
b41d260
VPR-59 fix(cms): make file path handling OS-independent
rlorenzo Jul 2, 2026
a1d17f2
VPR-59 chore(cms): resolve ReSharper and CodeQL findings
rlorenzo Jul 2, 2026
e328028
VPR-59 refactor(cms): cut Vue page complexity and duplication
rlorenzo Jul 2, 2026
990b950
VPR-59 test(cms): make import webroot fixtures OS-independent
rlorenzo Jul 2, 2026
2d243c8
VPR-59 fix(cms): stop anonymous content endpoint leaking file keys
rlorenzo Jul 2, 2026
65ebc76
VPR-59 fix(cms): harden file downloads against stored XSS
rlorenzo Jul 2, 2026
afd5bff
VPR-59 fix(cms): tighten left-nav validation and save integrity
rlorenzo Jul 2, 2026
3283392
VPR-59 fix(cms): small hardening and DI cleanups
rlorenzo Jul 2, 2026
1aa7ec0
VPR-59 test(cms): expand frontend coverage for files and editing
rlorenzo Jul 2, 2026
686d7dd
VPR-59 fix(cms): align validation namespace with file location
rlorenzo Jul 2, 2026
fb7a6c8
VPR-59 feat(cms): server-side paging for the content block list
rlorenzo Jul 2, 2026
a64e7e4
VPR-59 refactor(cms): dialog guard, filter consistency, token cleanup
rlorenzo Jul 2, 2026
ecf066c
VPR-59 fix(cms): surface truly recent blocks in the activity rail
rlorenzo Jul 2, 2026
503b027
VPR-59 feat(cms): mirror nav groups on the hub and surface activity
rlorenzo Jul 2, 2026
13b8ecc
VPR-59 refactor(cms): extract ActivityRow to satisfy the fallow gate
rlorenzo Jul 2, 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
2 changes: 1 addition & 1 deletion VueApp/src/CAHFS/router/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
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)
})
})
166 changes: 166 additions & 0 deletions VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts
Original file line number Diff line number Diff line change
@@ -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<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
},
}),
}))

const BLOCK = {
contentBlockId: 7,
content: "<p>current editor content</p>",
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: `<textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
}

async function mountEdit() {
const router = createTestRouter()
await router.push({ name: "CmsContentBlockEdit", params: { id: "7" } })
await router.isReady()
const wrapper = mountCms(ContentBlockEdit, { global: { stubs: { QEditor: qEditorStub } } }, router)
await flushPromises()
await flushPromises()
return wrapper
}

function diffButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Diff vs current"))!
}
function loadButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Load into editor"))!
}
function historySelect(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
// The version-history select is the one labeled "Select a previous version".
return wrapper.findAllComponents({ name: "QSelect" }).find((s) => s.props("label") === "Select a previous version")!
}

describe("ContentBlockEdit.vue - history selection gating", () => {
beforeEach(() => routeGet())

it("renders the diff/load buttons disabled until a version is selected", async () => {
const wrapper = await mountEdit()
expect(diffButton(wrapper).props("disable")).toBeTruthy()
expect(loadButton(wrapper).props("disable")).toBeTruthy()
})

it("selecting a version only sets selectedHistory and does not fetch that version", async () => {
const wrapper = await mountEdit()
const getCallsBefore = mockGet.mock.calls.length
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()
// No extra GET for the version content; the buttons just enable.
expect(mockGet.mock.calls.length).toBe(getCallsBefore)
expect(diffButton(wrapper).props("disable")).toBeFalsy()
expect(loadButton(wrapper).props("disable")).toBeFalsy()
})
})

describe("ContentBlockEdit.vue - diffAgainstCurrent", () => {
beforeEach(() => routeGet())

it("POSTs the current editor content to the diff endpoint and opens the dialog with the result", async () => {
mockPost.mockResolvedValue({
success: true,
result: { content: "<ins>new</ins>", hasComparison: true, hasChanges: true },
})
const wrapper = await mountEdit()
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()

await diffButton(wrapper).trigger("click")
await flushPromises()

expect(mockPost).toHaveBeenCalledOnce()
const [url, payload] = mockPost.mock.calls[0]!
expect(url).toContain("CMS/content/7/history/91/diff")
// The CURRENT editor content is posted (so unsaved edits are diffed).
expect(payload).toEqual({ content: "<p>current editor content</p>" })

const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
expect(dialog.props("modelValue")).toBeTruthy()
expect(dialog.props("diffHtml")).toContain("new")
expect(dialog.props("subtitle")).toContain("to your current editor content")
})

it("notifies and closes the diff dialog when the diff POST fails", async () => {
mockPost.mockResolvedValue({ success: false, errors: ["diff failed"] })
const wrapper = await mountEdit()
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()

await diffButton(wrapper).trigger("click")
await flushPromises()

const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
expect(dialog.props("modelValue")).toBeFalsy()
expect(document.body.textContent).toContain("diff failed")
})
})
Loading
Loading