130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { MENU_CODE_VALUES, PERMISSION_CODES } from "../src/app/permissions";
|
|
import { fallbackNavigation } from "../src/app/navigation/menu";
|
|
import { adminRoutes } from "../src/app/router/routeConfig";
|
|
|
|
type OpenApiOperation = {
|
|
operationId?: string;
|
|
"x-permission"?: string;
|
|
"x-permissions"?: string[];
|
|
};
|
|
|
|
type OpenApiDocument = {
|
|
paths?: Record<string, Record<string, OpenApiOperation>>;
|
|
};
|
|
|
|
type NavItem = {
|
|
children?: NavItem[];
|
|
code?: string;
|
|
path?: string;
|
|
permissionCode?: string;
|
|
};
|
|
|
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const contract = JSON.parse(readFileSync(path.join(rootDir, "contracts/admin-openapi.json"), "utf8")) as OpenApiDocument;
|
|
const errors: string[] = [];
|
|
const knownPermissions = new Set<string>(PERMISSION_CODES);
|
|
const knownMenuCodes = new Set<string>(MENU_CODE_VALUES);
|
|
const routesByPath = new Map<string, (typeof adminRoutes)[number]>();
|
|
const routesByMenuCode = new Map<string, (typeof adminRoutes)[number]>();
|
|
const routePermissions = new Set<string>();
|
|
|
|
for (const route of adminRoutes) {
|
|
if (!route.path) {
|
|
errors.push(`Route ${route.pageKey} is missing path`);
|
|
}
|
|
if (!route.menuCode) {
|
|
errors.push(`Route ${route.path} is missing menuCode`);
|
|
}
|
|
if (!route.permission) {
|
|
errors.push(`Route ${route.path} is missing permission`);
|
|
}
|
|
if (route.menuCode && !knownMenuCodes.has(route.menuCode)) {
|
|
errors.push(`Route ${route.path} uses unknown menuCode: ${route.menuCode}`);
|
|
}
|
|
if (route.permission && !knownPermissions.has(route.permission)) {
|
|
errors.push(`Route ${route.path} uses unknown permission: ${route.permission}`);
|
|
}
|
|
if (routesByPath.has(route.path)) {
|
|
errors.push(`Duplicate route path: ${route.path}`);
|
|
}
|
|
if (routesByMenuCode.has(route.menuCode)) {
|
|
errors.push(`Duplicate route menuCode: ${route.menuCode}`);
|
|
}
|
|
|
|
routesByPath.set(route.path, route);
|
|
routesByMenuCode.set(route.menuCode, route);
|
|
routePermissions.add(route.permission);
|
|
}
|
|
|
|
for (const item of flattenNavItems(fallbackNavigation as NavItem[])) {
|
|
if (item.code && !knownMenuCodes.has(item.code)) {
|
|
errors.push(`Fallback menu uses unknown menu code: ${item.code}`);
|
|
}
|
|
|
|
if (!item.path) {
|
|
continue;
|
|
}
|
|
|
|
const route = item.code ? routesByMenuCode.get(item.code) : undefined;
|
|
if (!route) {
|
|
errors.push(`Fallback menu ${item.code || item.path} has no matching route`);
|
|
continue;
|
|
}
|
|
if (route.path !== item.path) {
|
|
errors.push(`Fallback menu ${item.code} path mismatch: ${item.path} !== ${route.path}`);
|
|
}
|
|
if (item.permissionCode !== route.permission) {
|
|
errors.push(`Fallback menu ${item.code} permission mismatch: ${item.permissionCode} !== ${route.permission}`);
|
|
}
|
|
}
|
|
|
|
const operationIds = new Set<string>();
|
|
const apiPermissions = new Set<string>();
|
|
const httpMethods = new Set(["delete", "get", "patch", "post", "put"]);
|
|
|
|
for (const [endpointPath, pathItem] of Object.entries(contract.paths || {})) {
|
|
for (const [method, operation] of Object.entries(pathItem)) {
|
|
if (!httpMethods.has(method)) {
|
|
continue;
|
|
}
|
|
if (!operation.operationId) {
|
|
errors.push(`${method.toUpperCase()} ${endpointPath} is missing operationId`);
|
|
} else if (operationIds.has(operation.operationId)) {
|
|
errors.push(`Duplicate OpenAPI operationId: ${operation.operationId}`);
|
|
} else {
|
|
operationIds.add(operation.operationId);
|
|
}
|
|
|
|
const permissions = operation["x-permissions"]?.length
|
|
? operation["x-permissions"]
|
|
: operation["x-permission"]
|
|
? [operation["x-permission"]]
|
|
: [];
|
|
for (const permission of permissions) {
|
|
apiPermissions.add(permission);
|
|
if (!knownPermissions.has(permission)) {
|
|
errors.push(`${method.toUpperCase()} ${endpointPath} uses unknown x-permission: ${permission}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length) {
|
|
console.error("Admin architecture validation failed:");
|
|
for (const error of errors) {
|
|
console.error(`- ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`Admin architecture validation passed: ${adminRoutes.length} routes, ${operationIds.size} operations, ${apiPermissions.size} protected permissions.`
|
|
);
|
|
|
|
function flattenNavItems(items: NavItem[]): NavItem[] {
|
|
return items.flatMap((item) => [item, ...flattenNavItems(item.children || [])]);
|
|
}
|