317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
import { readdirSync, statSync } from "node:fs";
|
|
import * as path from "node:path";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { AdminSidebar } from "./AdminSidebar";
|
|
import type { SideBarItem } from "./SidebarItem";
|
|
import { UI_NAV_IDS } from "~/application/ui/uiRegistry";
|
|
import { canonicalAdminRouteExactPaths, canonicalAdminRouteSlugs, disabledPluginRouteSlugs } from "~/utils/app/PublicRouteSegments";
|
|
import { resolveRepoPath } from "~/utils/testing/repoPaths";
|
|
|
|
function flattenSidebarItems(items: SideBarItem[]): SideBarItem[] {
|
|
return items.flatMap((item) => [item, ...flattenSidebarItems(item.items ?? [])]);
|
|
}
|
|
|
|
function walkRouteFiles(directory: string): string[] {
|
|
return readdirSync(directory).flatMap((entry) => {
|
|
const absolutePath = path.join(directory, entry);
|
|
const stats = statSync(absolutePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
return walkRouteFiles(absolutePath);
|
|
}
|
|
|
|
if (!/\.(tsx?|jsx?)$/.test(entry) || /\.(test|spec)\.(tsx?|jsx?)$/.test(entry) || entry.endsWith(".server.ts")) {
|
|
return [];
|
|
}
|
|
|
|
return [absolutePath];
|
|
});
|
|
}
|
|
|
|
function routePathFromAdminRouteFile(absolutePath: string): string | null {
|
|
const routeRoot = resolveRepoPath("apps/web/app/routes/admin");
|
|
const relativePath = path.relative(routeRoot, absolutePath).replace(/\\/g, "/");
|
|
|
|
if (relativePath === "404.tsx" || relativePath.startsWith("internal/")) {
|
|
return null;
|
|
}
|
|
|
|
const withoutExtension = relativePath.replace(/\.(tsx?|jsx?)$/, "");
|
|
const parts = withoutExtension
|
|
.split("/")
|
|
.flatMap((part) => part.split("."))
|
|
.filter((part) => part && part !== "index" && !part.startsWith("__"));
|
|
|
|
return parts.join("/");
|
|
}
|
|
|
|
function collectAdminRoutePaths(): string[] {
|
|
return walkRouteFiles(resolveRepoPath("apps/web/app/routes/admin"))
|
|
.map(routePathFromAdminRouteFile)
|
|
.filter((routePath): routePath is string => routePath !== null);
|
|
}
|
|
|
|
function targetMatchesRoute(target: string, routePath: string): boolean {
|
|
const targetParts = target.split("/").filter(Boolean);
|
|
const routeParts = routePath.split("/").filter(Boolean);
|
|
|
|
if (targetParts.length !== routeParts.length) {
|
|
return false;
|
|
}
|
|
|
|
return routeParts.every((routePart, index) => routePart.startsWith("$") || routePart === targetParts[index]);
|
|
}
|
|
|
|
function normalizeSidebarPath(pathname: string): string | null {
|
|
if (!pathname.startsWith("/") || pathname === "/") {
|
|
return null;
|
|
}
|
|
|
|
return pathname.split(/[?#]/)[0]?.replace(/^\/+|\/+$/g, "") || null;
|
|
}
|
|
|
|
describe("AdminSidebar", () => {
|
|
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
|
|
|
it("returns an array of sidebar items", () => {
|
|
const sidebar = AdminSidebar({ t, appConfiguration: null });
|
|
const serialized = JSON.stringify(sidebar);
|
|
const allPaths = flattenSidebarItems(sidebar)
|
|
.map((item) => item.path)
|
|
.filter((path): path is string => Boolean(path) && path !== "/" && path.startsWith("/"));
|
|
|
|
expect(Array.isArray(sidebar)).toBe(true);
|
|
expect(serialized).toContain("/dashboard");
|
|
expect(serialized).toContain("/accounts");
|
|
expect(serialized).toContain("/audit-trails");
|
|
expect(serialized).toContain("/notifications/messages");
|
|
expect(serialized).not.toContain("/feature-flags");
|
|
expect(serialized).toContain("/settings/security");
|
|
expect(serialized).not.toContain("/settings/profile");
|
|
expect(serialized).not.toContain("/metrics");
|
|
expect(sidebar).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
items: expect.arrayContaining([expect.objectContaining({ id: "ui.nav.admin.dashboard", path: "/dashboard" })]),
|
|
}),
|
|
])
|
|
);
|
|
expect(sidebar.at(-1)?.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "ui.nav.admin.switchToApp", path: "/select-tenant", permission: "admin.accounts.view", exact: true }),
|
|
])
|
|
);
|
|
const emittedPaths = serialized.match(/"path":"[^"]*"/g) ?? [];
|
|
expect(emittedPaths.filter((path) => path.includes("/admin/"))).toEqual([]);
|
|
expect(serialized).not.toContain("/admin/pages");
|
|
expect(serialized).not.toContain("/admin/blog");
|
|
expect(serialized).not.toContain("/admin/crons");
|
|
expect(serialized).not.toContain("/email-marketing");
|
|
expect(serialized).not.toContain("/forms");
|
|
expect(serialized).not.toContain("/help-desk");
|
|
expect(serialized).not.toContain("/knowledge-base");
|
|
expect(serialized).not.toContain("/portals");
|
|
expect(serialized).not.toContain("/prompts");
|
|
expect(serialized).not.toContain("/workflow-engine");
|
|
expect(serialized).not.toContain('"path":"/crm"');
|
|
expect(serialized).not.toContain("ui.nav.admin.crm");
|
|
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
|
|
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).toEqual(expect.arrayContaining(["dashboard", "accounts", "settings"]));
|
|
allPaths.forEach((path) => {
|
|
const firstSlug = path.split("/").filter(Boolean)[0] ?? "";
|
|
const isCanonicalSlug = (canonicalAdminRouteSlugs as readonly string[]).includes(firstSlug);
|
|
const isCanonicalExactPath = (canonicalAdminRouteExactPaths as readonly string[]).includes(path);
|
|
expect(isCanonicalSlug || isCanonicalExactPath, path).toBe(true);
|
|
});
|
|
|
|
sidebar.forEach((item) => {
|
|
expect(typeof item.title).toBe("string");
|
|
expect(typeof item.path).toBe("string");
|
|
|
|
if (item.items) {
|
|
expect(Array.isArray(item.items)).toBe(true);
|
|
|
|
item.items.forEach((subItem) => {
|
|
expect(typeof subItem.title).toBe("string");
|
|
expect(typeof subItem.path).toBe("string");
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
it("consolidates platform operations and support without exposing feature flags", () => {
|
|
const sidebar = AdminSidebar({ t, appConfiguration: null });
|
|
const platform = sidebar.find((item) => item.id === "ui.nav.admin.platform");
|
|
const platformItems = platform?.items ?? [];
|
|
const operations = platformItems.find((item) => item.id === "ui.nav.admin.platform.operations");
|
|
const support = platformItems.find((item) => item.id === "ui.nav.admin.platform.support");
|
|
|
|
expect(platformItems.map((item) => item.id)).toEqual(["ui.nav.admin.platform.operations", "ui.nav.admin.platform.support", UI_NAV_IDS.adminApi]);
|
|
expect(operations).toMatchObject({
|
|
title: "admin.platformNavigation.operations",
|
|
path: "/system",
|
|
isCollapsible: false,
|
|
activePaths: ["/system", "/system/health", "/system/deploys", "/system/jobs", "/system/webhooks", "/system/billing"],
|
|
anyPermissions: ["admin.metrics.view", "admin.events.view", "admin.account.subscription"],
|
|
});
|
|
expect(operations?.items?.map((item) => item.path)).toEqual([
|
|
"/system",
|
|
"/system/health",
|
|
"/system/deploys",
|
|
"/system/jobs",
|
|
"/system/webhooks",
|
|
"/system/billing",
|
|
]);
|
|
const accounts = flattenSidebarItems(sidebar).find((item) => item.id === UI_NAV_IDS.adminAccounts);
|
|
const audit = flattenSidebarItems(sidebar).find((item) => item.id === UI_NAV_IDS.adminAudit);
|
|
expect(accounts?.items?.map((item) => item.path)).not.toContain("/accounts/billing");
|
|
expect(accounts?.excludePaths).toContain("/accounts/billing");
|
|
expect(audit?.items?.map((item) => item.path)).not.toContain("/webhooks");
|
|
expect(support).toMatchObject({
|
|
title: "admin.platformNavigation.support",
|
|
path: "/support",
|
|
anyPermissions: ["admin_identity.impersonation.start", "admin.metrics.view"],
|
|
isCollapsible: false,
|
|
activePaths: ["/support", "/support/kwp-contracts"],
|
|
});
|
|
expect(support?.items?.map((item) => item.path)).toEqual(["/support", "/support/kwp-contracts"]);
|
|
expect(platformItems.at(-1)).toMatchObject({
|
|
id: UI_NAV_IDS.adminApi,
|
|
title: "admin.platformNavigation.apiAccess",
|
|
path: "/api",
|
|
});
|
|
expect(flattenSidebarItems(sidebar).map((item) => item.id)).not.toContain(UI_NAV_IDS.adminFeatureFlags);
|
|
});
|
|
|
|
it("gates platform leads only by the sales CRM read permission", () => {
|
|
const leadInbox = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null })).find((item) => item.id === "ui.nav.admin.platformLeads");
|
|
expect(leadInbox).toMatchObject({
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
});
|
|
|
|
it("keeps sales work and communication separate while nesting configuration under settings", () => {
|
|
const sidebar = AdminSidebar({ t, appConfiguration: null });
|
|
const salesGroups = sidebar.filter((item) => ["ui.nav.admin.sales", "ui.nav.admin.salesCommunication"].includes(item.id ?? ""));
|
|
const settings = flattenSidebarItems(sidebar).find((item) => item.id === UI_NAV_IDS.adminSettings);
|
|
|
|
expect(salesGroups.map((group) => group.id)).toEqual(["ui.nav.admin.sales", "ui.nav.admin.salesCommunication"]);
|
|
expect(salesGroups.map((group) => group.title)).toEqual(["admin.sales.navigation.title", "admin.sales.navigation.communicationSection"]);
|
|
expect(salesGroups[0]?.items?.map((item) => item.id)).toEqual([
|
|
"ui.nav.admin.sales.today",
|
|
"ui.nav.admin.platformLeads",
|
|
"ui.nav.admin.sales.deals",
|
|
"ui.nav.admin.sales.contacts",
|
|
"ui.nav.admin.sales.companies",
|
|
UI_NAV_IDS.adminSalesImportExport,
|
|
]);
|
|
expect(salesGroups[1]?.items?.map((item) => item.id)).toEqual(["ui.nav.admin.sales.email", "ui.nav.admin.sales.calls", "ui.nav.admin.sales.bookings"]);
|
|
expect(salesGroups.flatMap((group) => group.items ?? []).every((item) => item.icon)).toBe(true);
|
|
expect(settings?.items?.slice(-4).map((item) => item.id)).toEqual([
|
|
UI_NAV_IDS.adminSalesPipelineSettings,
|
|
UI_NAV_IDS.adminSalesLeadSettings,
|
|
UI_NAV_IDS.adminSalesFieldsTags,
|
|
UI_NAV_IDS.adminSalesTelephonySettings,
|
|
]);
|
|
expect(settings).toMatchObject({
|
|
isCollapsible: false,
|
|
activePaths: ["/settings"],
|
|
anyPermissions: expect.arrayContaining(["admin_identity.sessions.read", "admin.settings.general.view", "admin.settings.danger.reset"]),
|
|
});
|
|
expect(settings?.items?.slice(0, 3).map((item) => item.id)).toEqual([
|
|
"ui.nav.admin.settings.profile",
|
|
"ui.nav.admin.settings.adminUsers",
|
|
"ui.nav.admin.settings.adminRoles",
|
|
]);
|
|
expect(flattenSidebarItems(sidebar).map((item) => item.id)).not.toContain("ui.nav.admin.adminUsers");
|
|
expect(flattenSidebarItems(sidebar).map((item) => item.id)).not.toContain("ui.nav.admin.salesConfiguration");
|
|
});
|
|
|
|
it("gates lead automation settings with the smallest assignment permission", () => {
|
|
const settings = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null })).find((item) => item.id === "ui.nav.admin.sales.leadSettings");
|
|
|
|
expect(settings).toMatchObject({
|
|
path: "/settings/leads",
|
|
permission: "admin_sales.crm.assign",
|
|
});
|
|
});
|
|
|
|
it("exposes the personal today list with CRM read permission", () => {
|
|
const today = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null })).find((item) => item.id === "ui.nav.admin.sales.today");
|
|
|
|
expect(today).toMatchObject({
|
|
path: "/sales/tasks/today",
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
});
|
|
|
|
it("exposes telephony workspace and settings with their smallest permissions", () => {
|
|
const items = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null }));
|
|
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.calls")).toMatchObject({
|
|
path: "/sales/calls",
|
|
permission: "admin_sales.phone.call",
|
|
});
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.telephonySettings")).toMatchObject({
|
|
path: "/settings/telephony",
|
|
permission: "admin_sales.integrations.manage",
|
|
});
|
|
});
|
|
|
|
it("exposes tenantless platform contacts with CRM read permission", () => {
|
|
const contacts = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null })).find((item) => item.id === "ui.nav.admin.sales.contacts");
|
|
|
|
expect(contacts).toMatchObject({
|
|
path: "/sales/contacts",
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
});
|
|
|
|
it("exposes every VA-P28b data screen through its smallest permission", () => {
|
|
const items = flattenSidebarItems(AdminSidebar({ t, appConfiguration: null }));
|
|
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.companies")).toMatchObject({
|
|
path: "/sales/companies",
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.pipelineSettings")).toMatchObject({
|
|
path: "/settings/pipelines",
|
|
permission: "admin_sales.crm.write",
|
|
});
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.fieldsTags")).toMatchObject({
|
|
path: "/settings/fields-tags",
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
expect(items.find((item) => item.id === "ui.nav.admin.sales.importExport")).toMatchObject({
|
|
path: "/sales/import-export",
|
|
permission: "admin_sales.crm.read",
|
|
});
|
|
});
|
|
|
|
it("only emits canonical admin sidebar paths backed by route files", () => {
|
|
const routePaths = collectAdminRoutePaths();
|
|
const sidebar = AdminSidebar({
|
|
t,
|
|
appConfiguration: {
|
|
app: {
|
|
features: {
|
|
tenantApiKeys: true,
|
|
},
|
|
},
|
|
} as any,
|
|
});
|
|
|
|
const sidebarTargets = flattenSidebarItems(sidebar)
|
|
.map((item) => item.path)
|
|
.filter((pathname): pathname is string => Boolean(pathname))
|
|
.map(normalizeSidebarPath)
|
|
.filter((pathname): pathname is string => pathname !== null);
|
|
|
|
expect(sidebarTargets).not.toContain("admin");
|
|
expect(sidebarTargets.map((pathname) => pathname.split("/")[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
|
|
|
|
const missingTargets = sidebarTargets.filter((target) => !routePaths.some((routePath) => targetMatchesRoute(target, routePath)));
|
|
expect(missingTargets).toEqual([]);
|
|
});
|
|
});
|