Files
govoplan-core/webui/tests/module-capabilities.test.ts
T

475 lines
21 KiB
TypeScript

import type {
AuthInfo,
DashboardWidgetsUiCapability,
OrganizationFunctionActionContext,
OrganizationFunctionActionContribution,
PlatformNavItem,
PlatformWebModule
} from "../src/types";
import {
hasUiCapability,
moduleInstalled,
moduleIntegrationEnabled,
routeContributionsForModules,
uiCapability
} from "../src/platform/moduleLogic";
import {
isViewSurfaceVisible,
viewSurfaceCatalogueForModules,
visibleRoutesForProjection
} from "../src/platform/views";
import { groupNavigationItems } from "../src/platform/productAreas";
import { inheritedNavigationLayout, materializeNavigationLayout, moveNavigationEntry, navigationEditorOrder } from "../src/components/navigationPreferenceLayout";
import {
composeProductSurfaces,
projectProductNavigation
} from "../src/platform/productSurfaces";
import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
const access: PlatformWebModule = { id: "access", label: "Access", version: "test" };
const filesCapability = { kind: "fileExplorer" };
const mailCapability = { kind: "mailProfiles" };
const files: PlatformWebModule = {
id: "files",
label: "Files",
version: "test",
routes: [{ path: "/files", order: 20, render: () => null }],
uiCapabilities: { "files.fileExplorer": filesCapability }
};
const mail: PlatformWebModule = {
id: "mail",
label: "Mail",
version: "test",
routes: [{ path: "/mail", order: 30, render: () => null }],
uiCapabilities: { "mail.profiles": mailCapability }
};
const campaigns: PlatformWebModule = {
id: "campaigns",
label: "Campaigns",
version: "test",
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
routes: [{ path: "/campaigns", order: 10, render: () => null }]
};
const cases: Array<{ name: string; modules: PlatformWebModule[]; files: boolean; mail: boolean }> = [
{ name: "core-only", modules: [access], files: false, mail: false },
{ name: "files-only", modules: [access, files], files: true, mail: false },
{ name: "mail-only", modules: [access, mail], files: false, mail: true },
{ name: "campaign-only", modules: [access, campaigns], files: false, mail: false },
{ name: "campaign-files", modules: [access, campaigns, files], files: true, mail: false },
{ name: "campaign-mail", modules: [access, campaigns, mail], files: false, mail: true },
{ name: "full-product", modules: [access, campaigns, files, mail], files: true, mail: true }
];
for (const testCase of cases) {
const campaignInstalled = moduleInstalled("campaigns", testCase.modules);
assert(moduleInstalled("access", testCase.modules), `${testCase.name}: access should be installed`);
assert(moduleInstalled("files", testCase.modules) === testCase.files, `${testCase.name}: files installed state`);
assert(moduleInstalled("mail", testCase.modules) === testCase.mail, `${testCase.name}: mail installed state`);
assert(hasUiCapability("files.fileExplorer", testCase.modules) === testCase.files, `${testCase.name}: files UI capability`);
assert(hasUiCapability("mail.profiles", testCase.modules) === testCase.mail, `${testCase.name}: mail UI capability`);
assert(moduleIntegrationEnabled("campaigns", "files", testCase.modules) === (campaignInstalled && testCase.files), `${testCase.name}: campaign files integration`);
assert(moduleIntegrationEnabled("campaigns", "mail", testCase.modules) === (campaignInstalled && testCase.mail), `${testCase.name}: campaign mail integration`);
}
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "files.recent",
surfaceId: "files.widget.recent",
title: "Recent files",
moduleId: "files",
supportedSizes: ["medium", "wide"],
defaultConfiguration: { limit: 10, includeFolders: false },
configurationFields: [
{
id: "limit",
label: "Items",
kind: "number",
min: 1,
max: 50
},
{
id: "includeFolders",
label: "Include folders",
kind: "boolean"
}
],
render: ({ configuration, effectiveView, instanceId }) =>
`${instanceId}:${effectiveView?.activeViewId ?? "full"}:${String(configuration.limit)}`
}
]
};
const dashboardProvider: PlatformWebModule = {
id: "dashboard-provider",
label: "Dashboard provider",
version: "test",
uiCapabilities: {
"dashboard.widgets": configurableDashboardWidgets
}
};
const resolvedDashboardWidgets = uiCapability<DashboardWidgetsUiCapability>(
"dashboard.widgets",
[dashboardProvider]
);
assert(
resolvedDashboardWidgets?.widgets[0].configurationFields?.length === 2,
"dashboard widget capabilities should retain provider-defined configuration"
);
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");
assert(scopeGrants("tenant:*", "calendar:event:read"), "tenant wildcard should grant tenant-level module scopes");
assert(!scopeGrants("tenant:*", "system:settings:read"), "tenant wildcard should not grant system scopes");
let selectedFunctionId = "";
const functionAction: OrganizationFunctionActionContribution = {
id: "test.view-assignments",
label: "View assignments",
icon: "assignments-icon",
order: 40,
anyOf: ["idm:organization_assignment:read"],
onClick: (context) => {
selectedFunctionId = context.function.id;
}
};
functionAction.onClick({
settings: { apiBaseUrl: "", apiKey: "", accessToken: "" },
auth: {} as OrganizationFunctionActionContext["auth"],
function: {
id: "function-1",
tenant_id: "tenant-1",
organization_unit_id: "unit-1",
slug: "test-function",
name: "Test function"
}
});
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
const productNavigation = [
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard.nav.dashboard" },
{ to: "/tasks", label: "Tasks", surfaceId: "tasks.nav.tasks" },
{ to: "/files", label: "Files", surfaceId: "files.nav.files" },
{ to: "/admin", label: "Administration", surfaceId: "admin.nav.admin" }
];
const productAreas = [
{
id: "work",
moduleId: "tasks",
label: "Work",
iconName: "list-checks" as const,
surfaceIds: ["tasks.nav.tasks"],
order: 10
},
{
id: "records-documents",
moduleId: "files",
label: "Records and documents",
iconName: "folder" as const,
surfaceIds: ["files.nav.files"],
order: 20
}
];
const groupedNavigation = groupNavigationItems(productNavigation, productAreas, {
navigationMode: "grouped",
productAreaOrder: ["records-documents", "work"],
productAreaLabels: { work: "My work" }
});
assert(
groupedNavigation.map((group) => group.id).join(",") ===
"overview,product-area:records-documents,product-area:work,more-tools",
"product-area navigation should preserve dashboard, configured order, and unclassified tools"
);
assert(
groupedNavigation[2]?.label === "My work",
"View presentation should override a product-area label"
);
assert(
groupNavigationItems(productNavigation, productAreas, { navigationMode: "flat" })[0]?.items.length === 4,
"flat navigation should retain every authorized destination"
);
const railItems = [
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", navigationCustomLayout: true, navigationLayoutSource: "tenant", navigationSection: { id: "separator:a", label: "A" } },
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "messages", navigationLocked: true, navigationCustomLayout: true, navigationLayoutSource: "tenant", navigationSection: { id: "separator:b", label: "B" } }
];
assert(groupNavigationItems(railItems, []).map((group) => group.label).join() === "A,B", "explicit separators survive module-default grouping and filtering");
const viewNavigation = { contract_version: "1" as const, order: ["messages", "separator:view", "files"], hidden: ["mail"], separators: [{ id: "separator:view", label: "View" }] };
const viewGroups = groupNavigationItems(railItems, [], { navigation: viewNavigation });
assert(viewGroups[0]?.items[0]?.to === "/mail", "View layout supports navigation aliases and cannot hide locked module");
assert(viewGroups[1]?.label === "View" && viewGroups.flatMap((group) => group.items).length === 2, "View separators retain every authorized item once");
assert(groupNavigationItems(railItems.map((item) => ({ ...item, navigationLayoutSource: "user" })), [], { navigation: viewNavigation })[0]?.items[0]?.to === "/files", "personal layout takes precedence over View presentation order");
assert(groupNavigationItems(railItems.map((item) => ({ ...item, navigationVisibilitySource: "user" })), [], { navigation: { ...viewNavigation, hidden: ["files"] } }).flatMap((group) => group.items).some((item) => item.to === "/files"), "legacy personal visibility-only preferences also take precedence over View navigation hiding");
const inheritedRail = inheritedNavigationLayout(railItems, "system", []);
assert(inheritedRail.order.join() === "files,messages", "system baseline ignores current tenant grouping");
const materialized = materializeNavigationLayout({ contract_version: "1", order: ["mail", "files"], hidden: [] }, { contract_version: "1", order: ["separator:work", "files", "mail"], hidden: [], separators: [{ id: "separator:work", label: "Work" }] });
assert(materialized.order.join() === "separator:work,mail,files", "legacy drafts gain inherited separator without losing their item order");
assert(moveNavigationEntry(["a", "separator:b", "c"], "c", "a").join() === "c,a,separator:b", "drag reorder treats separators as ordinary ordered entries");
assert(navigationEditorOrder(railItems, { contract_version: "1", order: ["files", "messages"], hidden: ["files"], separators: [] }).join() === "messages", "removed modules leave editor list while remaining available for re-add");
const productSurfaceExplanation = {
reason: "authorization" as const,
title: "Messages are unavailable",
description: "No message source is available for the current responsibility.",
resolution: "Ask the responsible administrator to review the assignment."
};
const messageSurfaceModules: PlatformWebModule[] = [
{
id: "mail",
label: "Mail",
version: "test",
productSurfaces: [{
contractVersion: "1",
id: "communication.messages",
moduleId: "mail",
label: "Messages",
description: "Read messages without merging channel custody.",
iconName: "mail",
entryPath: "/messages",
routePath: "/mail",
surfaceIds: ["mail.route.mail"],
presentations: ["task", "reader"],
capabilityIds: [],
searchSourceIds: ["mail.mailbox_messages"],
helpContextIds: ["mail.quick_access.messages"],
documentationTopicIds: ["mail.quick-access-and-product-area"],
allOf: [],
anyOf: ["mail:mailbox:read"],
aliases: ["/inbox"],
order: 10,
unavailable: productSurfaceExplanation
}]
},
{
id: "postbox",
label: "Postbox",
version: "test",
productSurfaceMetadata: [{
contract_version: "1",
id: "communication.messages",
module_id: "postbox",
label: "Messages",
description: "Read messages without merging channel custody.",
icon: "mail",
entry_path: "/messages",
route_path: "/postbox",
surface_ids: ["postbox.route.postbox"],
presentations: ["task", "reader"],
capability_ids: [],
search_source_ids: ["postbox.messages"],
help_context_ids: ["postbox.quick_access.messages"],
documentation_topic_ids: ["postbox.quick-access-and-product-area"],
required_all: [],
required_any: ["postbox:message:read"],
aliases: ["/inbox"],
order: 20,
unavailable: productSurfaceExplanation
}]
}
];
const composedMessages = composeProductSurfaces(messageSurfaceModules);
assert(composedMessages.length === 1, "related owner routes should compose into one product identity");
assert(composedMessages[0]?.entryPath === "/messages", "the composed identity should keep its stable entry path");
assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance");
assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners");
const messageNavigation = projectProductNavigation(
[
{ to: "/dashboard", label: "Dashboard", order: 1 },
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] },
{ to: "/admin", label: "Administration", order: 90, anyOf: ["core:admin:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo
);
assert(
messageNavigation.primaryItems.map((item) => item.to).join(",") === "/dashboard,/messages",
"ordinary navigation should replace authorized owner routes with one stable product entry"
);
assert(
messageNavigation.primaryItems.find((item) => item.to === "/messages")?.activePaths?.includes("/postbox"),
"the product entry should remain active on an owner route"
);
assert(
messageNavigation.allToolItems.map((item) => item.to).join(",") === "/dashboard,/mail,/postbox",
"the explicit tool catalogue should retain authorized technical routes"
);
const mailOnlyNavigation = projectProductNavigation(
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read"] } as AuthInfo
);
const reorderedOwners: PlatformNavItem[] = [
{ to: "/postbox", label: "Postbox", order: 0, navigationId: "postbox.navigation.postbox", surfaceId: "postbox.navigation.postbox", anyOf: ["postbox:message:read"], navigationLocked: true, navigationLockSource: "system", navigationCustomLayout: true, navigationSection: { id: "separator:locked", label: "Locked group" } },
{ to: "/files", label: "Files", order: 1 },
{ to: "/mail", label: "Mail", order: 2, navigationId: "mail.navigation.mail", surfaceId: "mail.navigation.mail", anyOf: ["mail:mailbox:read"] }
];
const reorderedProductNavigation = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo);
const reorderedMessages = reorderedProductNavigation.primaryItems[0];
assert(reorderedProductNavigation.primaryItems.map((item) => item.to).join() === "/messages,/files", "composed placement follows earliest effective owner order rather than fixed contributor priority");
assert(reorderedMessages.navigationSection?.id === "separator:locked", "composed entry keeps the earliest owner separator");
assert(reorderedMessages.navigationAliases?.includes("mail.navigation.mail") && reorderedMessages.navigationAliases?.includes("postbox.navigation.postbox"), "composition retains every consumed owner navigation ID for layout aliases");
assert(reorderedMessages.navigationLocked && reorderedMessages.navigationLockSource === "system", "a secondary owner's system lock survives composition with an unlocked primary owner");
assert(composeProductSurfaces(messageSurfaceModules)[0].contributors[0].moduleId === "mail", "custom rail placement never changes operational contributor priority");
const lockedAfterView = groupNavigationItems(reorderedProductNavigation.primaryItems, [], { navigation: { contract_version: "1", order: [], hidden: ["communication.messages", "mail.navigation.mail", "postbox.navigation.postbox"], separators: [] } });
assert(lockedAfterView.flatMap((group) => group.items).some((item) => item.to === "/messages"), "View aliases cannot hide a composed entry locked by any consumed authorized owner");
const aliasOrdered = groupNavigationItems(reorderedProductNavigation.primaryItems, [], { navigation: { contract_version: "1", order: ["/files", "separator:view", "mail.navigation.mail"], hidden: [], separators: [{ id: "separator:view", label: "View group" }] } });
assert(aliasOrdered[0]?.items[0]?.to === "/files" && aliasOrdered[1]?.items[0]?.to === "/messages" && aliasOrdered[1]?.label === "View group", "View ordering recognizes non-placement owner aliases and assigns the requested separator exactly once");
const unauthorizedLockedOwner = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems.find((item) => item.to === "/messages");
assert(unauthorizedLockedOwner?.navigationLocked === false && !unauthorizedLockedOwner.navigationAliases?.includes("postbox.navigation.postbox"), "unavailable optional owners contribute neither aliases nor lock metadata");
assert(
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"composition should remain stable when only one optional contributor is authorized"
);
assert(
mailOnlyNavigation.allToolItems.map((item) => item.to).join(",") === "/mail",
"the tool catalogue must not disclose unauthorized owner routes"
);
const focusedMessageNavigation = projectProductNavigation(
[{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo,
null,
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
]
);
assert(
focusedMessageNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"a focused View should keep the selected stable product destination"
);
assert(
focusedMessageNavigation.allToolItems.map((item) => item.to).join(",") === "/mail,/postbox",
"All available tools should provide an explicit permission-derived escape from View focus"
);
const viewAwareFiles: PlatformWebModule = {
...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
viewSurfaces: [
{
id: "files.settings.connectors",
moduleId: "files",
kind: "section",
label: "File connectors"
}
]
};
const viewCatalogue = viewSurfaceCatalogueForModules([viewAwareFiles]);
assert(
viewCatalogue.some((surface) => surface.id === "files.module"),
"view catalogue should derive a module root"
);
assert(
viewCatalogue.some((surface) => surface.id === "files.nav.files"),
"view catalogue should derive navigation surfaces"
);
assert(
viewCatalogue.some((surface) => surface.id === "files.route.files"),
"view catalogue should derive route surfaces"
);
const serverNormalizedCatalogue = viewSurfaceCatalogueForModules([
{
...viewAwareFiles,
viewSurfaces: [
{
id: "files.module",
moduleId: "files",
kind: "module",
label: "Files",
parentId: null
},
...(viewAwareFiles.viewSurfaces ?? [])
]
}
]);
assert(
serverNormalizedCatalogue.find((surface) => surface.id === "files.module")
?.parentId == null,
"server-normalized module roots must not become their own parent"
);
const filesView = {
activeViewId: "view-files",
activeRevisionId: "revision-files",
activeViewName: "Files only",
visibleSurfaceIds: [
"files.module",
"files.nav.files",
"files.route.files"
],
locked: false,
availableViews: [],
provenance: [],
diagnostics: []
};
assert(
isViewSurfaceVisible(filesView, "files.route.files", viewCatalogue),
"selected child surfaces should remain visible when their ancestor is selected"
);
assert(
!isViewSurfaceVisible(filesView, "files.settings.connectors", viewCatalogue),
"unselected sections should be hidden"
);
assert(
visibleRoutesForProjection([viewAwareFiles], filesView).length === 1,
"selected routes should remain in the effective route list"
);
const unauthorizedFilesUser = {
user: { id: "user-1", account_id: "account-1", email: "viewer@example.test" },
tenant: { id: "tenant-1", slug: "test", name: "Test" },
scopes: [],
roles: [],
groups: [],
principal: {
account_id: "account-1",
tenant_id: "tenant-1",
scopes: [],
group_ids: [],
auth_method: "session"
},
profile_loaded: true,
roles_loaded: true,
groups_loaded: true
} satisfies AuthInfo;
assert(
isViewSurfaceVisible(filesView, "files.nav.files", viewCatalogue) &&
!hasAnyScope(unauthorizedFilesUser, ["files:file:read"]),
"View visibility must not add the Access permission required by a module"
);
const missingParentView = {
...filesView,
visibleSurfaceIds: ["files.route.files"]
};
assert(
!isViewSurfaceVisible(
missingParentView,
"files.route.files",
viewCatalogue
),
"a selected child should stay hidden when its module ancestor is not selected"
);
assert(
isViewSurfaceVisible(filesView, "future.module.action", viewCatalogue),
"unknown surfaces should fail open for forward compatibility and recovery"
);