fix: close housekeeping foundation review findings
This commit is contained in:
1 parent
ebc263da35
commit
08f8d54888
21 files changed
+593
-46
No files matched your search
@@ -17,6 +17,17 @@ export const contentManifest = {
|
||||
PERMS.EVENTS_VIEW,
|
||||
PERMS.POLLS_VIEW,
|
||||
PERMS.PREFIXES_VIEW,
|
||||
PERMS.NEWS_EDIT,
|
||||
PERMS.PAGES_EDIT,
|
||||
PERMS.BANNERS_EDIT,
|
||||
PERMS.EVENTS_EDIT,
|
||||
PERMS.POLLS_EDIT,
|
||||
PERMS.PREFIXES_EDIT,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -10,6 +10,14 @@ export const economyManifest = {
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
iconId: "gem",
|
||||
previewHref: "/admin-next/economy",
|
||||
capability: anyCapability(PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW),
|
||||
capability: anyCapability(
|
||||
PERMS.CATALOG_VIEW,
|
||||
PERMS.SHOP_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -14,6 +14,14 @@ export const hotelManifest = {
|
||||
PERMS.ROOMS_VIEW,
|
||||
PERMS.RADIO_VIEW,
|
||||
PERMS.ASSETS_IMPORT,
|
||||
PERMS.ROOMS_EDIT,
|
||||
PERMS.ROOMS_DELETE,
|
||||
PERMS.RADIO_EDIT,
|
||||
PERMS.PAGES_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -12,4 +12,7 @@ export const operationsManifest = {
|
||||
previewHref: "/admin-next/operations",
|
||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -21,6 +21,21 @@ export const peopleManifest = {
|
||||
PERMS.MOD_TICKETS_VIEW,
|
||||
PERMS.MOD_USERS_VIEW,
|
||||
PERMS.MOD_BANS_VIEW,
|
||||
PERMS.USERS_EDIT,
|
||||
PERMS.USERS_BAN,
|
||||
PERMS.USERS_RESET_PASSWORD,
|
||||
PERMS.MODERATION_EDIT,
|
||||
PERMS.TICKETS_EDIT,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
PERMS.WORDFILTER_VIEW,
|
||||
PERMS.WORDFILTER_EDIT,
|
||||
PERMS.MOD_ACTIONS,
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -18,6 +18,11 @@ export const systemManifest = {
|
||||
PERMS.NOTIFICATIONS_VIEW,
|
||||
PERMS.PERMISSIONS_MANAGE,
|
||||
PERMS.RCON_EXECUTE,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
PERMS.NOTIFICATIONS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -40,6 +40,9 @@ const searchResult = {
|
||||
} satisfies HousekeepingSearchResult;
|
||||
|
||||
const searchProvider: HousekeepingSearchProvider = {
|
||||
id: "people.users",
|
||||
owner: "people",
|
||||
capability,
|
||||
search: async () => ok([searchResult], "search-1"),
|
||||
};
|
||||
|
||||
@@ -49,10 +52,17 @@ const inboxSourceResult = {
|
||||
} satisfies HousekeepingInboxSourceResult;
|
||||
|
||||
const inboxSource: HousekeepingInboxSource = {
|
||||
id: "people.tickets",
|
||||
owner: "people",
|
||||
capability,
|
||||
getItems: async () => ok(inboxSourceResult, "inbox-1"),
|
||||
};
|
||||
|
||||
const widget: HousekeepingWidgetDefinition = {
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability,
|
||||
kind: "mandatory",
|
||||
load: async () => ok({ count: 2 }, "widget-1"),
|
||||
};
|
||||
|
||||
@@ -80,6 +90,9 @@ const manifest: HousekeepingDomainManifest = {
|
||||
previewHref: "/admin-next/people",
|
||||
capability,
|
||||
routes: [],
|
||||
searchProviders: [searchProvider],
|
||||
inboxSources: [inboxSource],
|
||||
widgets: [widget],
|
||||
};
|
||||
|
||||
describe("housekeeping foundation contracts", () => {
|
||||
@@ -118,4 +131,23 @@ describe("housekeeping foundation contracts", () => {
|
||||
requirements,
|
||||
]).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("carries stable ownership and capability metadata for registry entries", () => {
|
||||
expect(searchProvider).toMatchObject({
|
||||
id: "people.users",
|
||||
owner: "people",
|
||||
capability,
|
||||
});
|
||||
expect(inboxSource).toMatchObject({
|
||||
id: "people.tickets",
|
||||
owner: "people",
|
||||
capability,
|
||||
});
|
||||
expect(widget).toMatchObject({
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability,
|
||||
kind: "mandatory",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { CapabilityRequirement } from "./capability";
|
||||
import type { HousekeepingInboxSource } from "./inbox";
|
||||
import type { HousekeepingSearchProvider } from "./search";
|
||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||
|
||||
export interface HousekeepingRouteDefinition {
|
||||
id: string;
|
||||
@@ -17,4 +20,7 @@ export interface HousekeepingDomainManifest {
|
||||
previewHref: `/admin-next/${HousekeepingDomainId}`;
|
||||
capability: CapabilityRequirement;
|
||||
routes: readonly HousekeepingRouteDefinition[];
|
||||
searchProviders: readonly HousekeepingSearchProvider[];
|
||||
inboxSources: readonly HousekeepingInboxSource[];
|
||||
widgets: readonly HousekeepingWidgetDefinition[];
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export interface HousekeepingInboxSourceResult {
|
||||
}
|
||||
|
||||
export interface HousekeepingInboxSource {
|
||||
id: string;
|
||||
owner: HousekeepingDomainId;
|
||||
capability: CapabilityRequirement;
|
||||
getItems(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
|
||||
@@ -30,4 +30,7 @@ export type {
|
||||
HousekeepingSearchProvider,
|
||||
HousekeepingSearchResult,
|
||||
} from "./search";
|
||||
export type { HousekeepingWidgetDefinition } from "./widget";
|
||||
export type {
|
||||
HousekeepingWidgetDefinition,
|
||||
HousekeepingWidgetKind,
|
||||
} from "./widget";
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { HousekeepingCapabilityContext } from "./capability";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export interface HousekeepingSearchInput {
|
||||
@@ -15,6 +18,9 @@ export interface HousekeepingSearchResult {
|
||||
}
|
||||
|
||||
export interface HousekeepingSearchProvider {
|
||||
id: string;
|
||||
owner: HousekeepingDomainId;
|
||||
capability: CapabilityRequirement;
|
||||
search(
|
||||
context: HousekeepingCapabilityContext,
|
||||
input: HousekeepingSearchInput,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { HousekeepingCapabilityContext } from "./capability";
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "./capability";
|
||||
import type { HousekeepingResult } from "./result";
|
||||
|
||||
export type HousekeepingWidgetKind = "mandatory" | "optional";
|
||||
|
||||
export interface HousekeepingWidgetDefinition {
|
||||
id: string;
|
||||
owner: HousekeepingDomainId;
|
||||
capability: CapabilityRequirement;
|
||||
kind: HousekeepingWidgetKind;
|
||||
load(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingResult<unknown>>;
|
||||
|
||||
@@ -265,6 +265,8 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
}
|
||||
} else if (value.type === "ImportExpression") {
|
||||
recordArgument(value.source, "import");
|
||||
} else if (value.type === "TSImportType") {
|
||||
recordArgument(value.argument, "import");
|
||||
} else if (
|
||||
value.type === "CallExpression" ||
|
||||
value.type === "OptionalCallExpression"
|
||||
@@ -445,6 +447,12 @@ describe("housekeeping runtime import boundary", () => {
|
||||
'import workflow from "../domains/people/workflow";',
|
||||
"src/features/housekeeping/domains/people/workflow",
|
||||
],
|
||||
[
|
||||
"TypeScript import type database access",
|
||||
"src/features/housekeeping/foundation/registry.ts",
|
||||
'type PrismaClient = import("@prisma/client").PrismaClient;',
|
||||
"@prisma/client",
|
||||
],
|
||||
] as const)("detects %s", (_name, sourceFile, source, expectedPath) => {
|
||||
expect(
|
||||
findHousekeepingImportBoundaryViolations(source, sourceFile),
|
||||
@@ -510,7 +518,7 @@ describe("housekeeping runtime import boundary", () => {
|
||||
'import database from "@/lib/database";',
|
||||
'import authentication from "@/lib/authentication";',
|
||||
'import commandKit from "cmdkit";',
|
||||
'import manifest from "../domains/people/manifest";',
|
||||
'import manifest from "./domains/people/manifest";',
|
||||
"const documentation = \"import db from '@/lib/db'\";",
|
||||
'// import action from "@/actions/users";',
|
||||
].join("\n");
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("housekeeping navigation", () => {
|
||||
previewHref: "/admin-next/people",
|
||||
capability: anyCapability(PERMS.MOD_CFH_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
@@ -35,6 +38,9 @@ describe("housekeeping navigation", () => {
|
||||
previewHref: "/admin-next/economy",
|
||||
capability: anyCapability(PERMS.CATALOG_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -79,6 +85,9 @@ describe("housekeeping navigation", () => {
|
||||
capability: anyCapability(PERMS.BANS_VIEW),
|
||||
},
|
||||
],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
@@ -88,6 +97,9 @@ describe("housekeeping navigation", () => {
|
||||
previewHref: "/admin-next/system",
|
||||
capability: anyCapability(PERMS.SETTINGS_VIEW),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
},
|
||||
]);
|
||||
const translate = vi.fn((key: string) => `translated:${key}`);
|
||||
@@ -118,7 +130,7 @@ describe("housekeeping navigation", () => {
|
||||
expect(translate).not.toHaveBeenCalledWith("system.title");
|
||||
expect(translate).not.toHaveBeenCalledWith("system.description");
|
||||
});
|
||||
it("shows the real People manifest only for its view capabilities", () => {
|
||||
it("shows real domains to operators authorized by owned migration capabilities", () => {
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
const translate = (key: string) => key;
|
||||
|
||||
@@ -137,15 +149,21 @@ describe("housekeeping navigation", () => {
|
||||
).map((domain) => domain.id),
|
||||
).not.toContain("economy");
|
||||
|
||||
for (const slug of [PERMS.MOD_ACTIONS, PERMS.MOD_CFH_EDIT]) {
|
||||
for (const [slug, expectedDomain] of [
|
||||
[PERMS.MOD_ACTIONS, "people"],
|
||||
[PERMS.USERS_EDIT, "people"],
|
||||
[PERMS.SETTINGS_VIEW, "people"],
|
||||
[PERMS.NEWS_EDIT, "content"],
|
||||
[PERMS.SHOP_EDIT, "economy"],
|
||||
[PERMS.ROOMS_EDIT, "hotel"],
|
||||
] as const) {
|
||||
const visibleDomainIds = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([slug]),
|
||||
translate,
|
||||
).map((domain) => domain.id);
|
||||
|
||||
expect(visibleDomainIds).not.toContain("people");
|
||||
expect(visibleDomainIds).not.toContain("economy");
|
||||
expect(visibleDomainIds, slug).toContain(expectedDomain);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/messages/en.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import { HousekeepingPageShell } from "./housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "./housekeeping-page-state";
|
||||
|
||||
@@ -10,20 +12,29 @@ describe("HousekeepingPageState", () => {
|
||||
["partial", "status"],
|
||||
["error", "alert"],
|
||||
] as const)("renders %s with the %s role", (state, role) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>,
|
||||
);
|
||||
const pageState =
|
||||
state === "partial" ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
) : (
|
||||
<HousekeepingPageState
|
||||
state={state}
|
||||
title={`${state} title`}
|
||||
description={`${state} description`}
|
||||
/>
|
||||
);
|
||||
const html = renderToStaticMarkup(pageState);
|
||||
|
||||
expect(html).toContain(`role="${role}"`);
|
||||
expect(html).toContain(`>${state} title<`);
|
||||
expect(html).toContain(`>${state} description<`);
|
||||
});
|
||||
|
||||
it("announces loading politely and makes the partial warning visible", () => {
|
||||
it("announces loading politely", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="loading"
|
||||
@@ -31,20 +42,33 @@ describe("HousekeepingPageState", () => {
|
||||
description="Loading description"
|
||||
/>,
|
||||
);
|
||||
const partial = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
title="Partial title"
|
||||
description="Partial description"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(loading).toContain('aria-live="polite"');
|
||||
expect(partial).toContain("--admin-warning-border");
|
||||
expect(partial).toContain("--admin-warning-subtle");
|
||||
expect(partial).toContain(">Partial title<");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["English", en.pages.housekeeping.states.partial],
|
||||
["Italian", itMessages.pages.housekeeping.states.partial],
|
||||
] as const)(
|
||||
"preserves the partial-state heading and separate localized warning in %s",
|
||||
(_locale, copy) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel={copy.label}
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toMatch(new RegExp(`<h2[^>]*>${copy.title}</h2>`));
|
||||
expect(html).toContain(`>${copy.label}</p>`);
|
||||
expect(html).toContain('data-state-tone="warning"');
|
||||
expect(html).toContain("--admin-warning-border");
|
||||
expect(html).toContain("--admin-warning-subtle");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders supplied retry content for an error without owning a callback", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HousekeepingPageState
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface HousekeepingPageStateProps {
|
||||
state: "loading" | "empty" | "partial" | "error";
|
||||
interface HousekeepingPageStateBaseProps {
|
||||
title: string;
|
||||
description: string;
|
||||
retryAction?: ReactNode;
|
||||
}
|
||||
|
||||
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
|
||||
(
|
||||
| { state: "partial"; partialLabel: string }
|
||||
| { state: "loading" | "empty" | "error"; partialLabel?: never }
|
||||
);
|
||||
|
||||
export function HousekeepingPageState({
|
||||
state,
|
||||
partialLabel,
|
||||
title,
|
||||
description,
|
||||
retryAction,
|
||||
@@ -31,13 +37,12 @@ export function HousekeepingPageState({
|
||||
{isPartial ? (
|
||||
<p
|
||||
data-state-tone="warning"
|
||||
className="font-medium text-[var(--admin-warning)]"
|
||||
className="mb-2 inline-flex rounded-full border border-[var(--admin-warning-border)] bg-[var(--admin-surface)] px-2 py-0.5 text-xs font-medium text-[var(--admin-warning)]"
|
||||
>
|
||||
{title}
|
||||
{partialLabel}
|
||||
</p>
|
||||
) : (
|
||||
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||
)}
|
||||
) : null}
|
||||
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
@@ -98,6 +98,9 @@ const forbiddenModuleRoots = [
|
||||
"src/app/actions",
|
||||
"src/app/admin",
|
||||
"src/app/mod",
|
||||
"@prisma/client",
|
||||
"drizzle-orm",
|
||||
"mysql2",
|
||||
] as const;
|
||||
|
||||
interface BabelNode {
|
||||
@@ -287,10 +290,6 @@ function canonicalizeLocalSpecifier(
|
||||
const suffixIndex = specifier.search(/[?#]/);
|
||||
const withoutSuffix =
|
||||
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
|
||||
const slashNormalized = withoutSuffix.replaceAll("\\", "/");
|
||||
if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) {
|
||||
return { candidates: [], violation: null };
|
||||
}
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
@@ -305,12 +304,15 @@ function canonicalizeLocalSpecifier(
|
||||
} else if (decoded.startsWith(".")) {
|
||||
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
|
||||
} else {
|
||||
return { candidates: [], violation: "<malformed local specifier>" };
|
||||
normalized = posix.normalize(decoded);
|
||||
}
|
||||
|
||||
const extensionless = normalized.replace(resolverExtensionPattern, "");
|
||||
return {
|
||||
candidates: [...new Set([normalized, extensionless])],
|
||||
candidates:
|
||||
decoded.startsWith("@/") || decoded.startsWith(".")
|
||||
? [...new Set([normalized, extensionless])]
|
||||
: [normalized],
|
||||
violation: null,
|
||||
};
|
||||
}
|
||||
@@ -785,6 +787,24 @@ describe("preview route import boundary", () => {
|
||||
'import("../mod/users/page")',
|
||||
"src/app/mod/users/page",
|
||||
],
|
||||
[
|
||||
"Prisma TypeScript import type",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'type PrismaClient = import("@prisma/client").PrismaClient;',
|
||||
"@prisma/client",
|
||||
],
|
||||
[
|
||||
"Drizzle package import",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import { sql } from "drizzle-orm";',
|
||||
"drizzle-orm",
|
||||
],
|
||||
[
|
||||
"mysql2 package import",
|
||||
"src/app/admin-next/page.tsx",
|
||||
'import type { Pool } from "mysql2";',
|
||||
"mysql2",
|
||||
],
|
||||
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
|
||||
expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
|
||||
expectedPath,
|
||||
@@ -824,6 +844,9 @@ describe("preview route import boundary", () => {
|
||||
'import dbTools from "../../lib/db-tools.ts";',
|
||||
'import auth from "../../lib/authentication";',
|
||||
'import preview from "../admin-next-shared";',
|
||||
'import prismaTools from "@prisma/client-tools";',
|
||||
'import drizzleTools from "drizzle-orm-kit";',
|
||||
'import mysqlTools from "mysql2-wrapper";',
|
||||
"const documentation = \"import db from '../../lib/db'\";",
|
||||
'const rawTemplate = `import("../../actions/users")`;',
|
||||
'// import db from "../../lib/db";',
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
import {
|
||||
HOUSEKEEPING_DOMAIN_IDS,
|
||||
type HousekeepingDomainId,
|
||||
} from "../migration/types";
|
||||
import { anyCapability, type HousekeepingDomainManifest } from "./contracts";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingSearchProvider,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "./contracts";
|
||||
import { createHousekeepingRegistry } from "./registry";
|
||||
|
||||
const manifest = (
|
||||
@@ -19,6 +28,45 @@ const manifest = (
|
||||
previewHref: `/admin-next/${id}`,
|
||||
capability: anyCapability(capabilitySlug),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
});
|
||||
|
||||
const providerCapability = anyCapability(PERMS.USERS_VIEW);
|
||||
|
||||
const searchProvider = (
|
||||
id: string,
|
||||
owner: HousekeepingDomainId = "people",
|
||||
capability: CapabilityRequirement = providerCapability,
|
||||
): HousekeepingSearchProvider => ({
|
||||
id,
|
||||
owner,
|
||||
capability,
|
||||
search: async () => ok([], "search"),
|
||||
});
|
||||
|
||||
const inboxSource = (
|
||||
id: string,
|
||||
owner: HousekeepingDomainId = "people",
|
||||
capability: CapabilityRequirement = providerCapability,
|
||||
): HousekeepingInboxSource => ({
|
||||
id,
|
||||
owner,
|
||||
capability,
|
||||
getItems: async () => ok({ items: [], availability: "available" }, "inbox"),
|
||||
});
|
||||
|
||||
const widget = (
|
||||
id: string,
|
||||
owner: HousekeepingDomainId = "people",
|
||||
capability: CapabilityRequirement = providerCapability,
|
||||
): HousekeepingWidgetDefinition => ({
|
||||
id,
|
||||
owner,
|
||||
capability,
|
||||
kind: "optional",
|
||||
load: async () => ok(null, "widget"),
|
||||
});
|
||||
|
||||
const expectedManifests = [
|
||||
@@ -47,6 +95,18 @@ const expectedManifests = [
|
||||
PERMS.MOD_TICKETS_VIEW,
|
||||
PERMS.MOD_USERS_VIEW,
|
||||
PERMS.MOD_BANS_VIEW,
|
||||
PERMS.USERS_EDIT,
|
||||
PERMS.USERS_BAN,
|
||||
PERMS.USERS_RESET_PASSWORD,
|
||||
PERMS.MODERATION_EDIT,
|
||||
PERMS.TICKETS_EDIT,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
PERMS.WORDFILTER_VIEW,
|
||||
PERMS.WORDFILTER_EDIT,
|
||||
PERMS.MOD_ACTIONS,
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -62,6 +122,14 @@ const expectedManifests = [
|
||||
PERMS.EVENTS_VIEW,
|
||||
PERMS.POLLS_VIEW,
|
||||
PERMS.PREFIXES_VIEW,
|
||||
PERMS.NEWS_EDIT,
|
||||
PERMS.PAGES_EDIT,
|
||||
PERMS.BANNERS_EDIT,
|
||||
PERMS.EVENTS_EDIT,
|
||||
PERMS.POLLS_EDIT,
|
||||
PERMS.PREFIXES_EDIT,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,7 +138,12 @@ const expectedManifests = [
|
||||
previewHref: "/admin-next/economy",
|
||||
labelKey: "pages.housekeeping.domains.economy.title",
|
||||
descriptionKey: "pages.housekeeping.domains.economy.description",
|
||||
slugs: [PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW],
|
||||
slugs: [
|
||||
PERMS.CATALOG_VIEW,
|
||||
PERMS.SHOP_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
@@ -78,7 +151,16 @@ const expectedManifests = [
|
||||
previewHref: "/admin-next/hotel",
|
||||
labelKey: "pages.housekeeping.domains.hotel.title",
|
||||
descriptionKey: "pages.housekeeping.domains.hotel.description",
|
||||
slugs: [PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, PERMS.ASSETS_IMPORT],
|
||||
slugs: [
|
||||
PERMS.ROOMS_VIEW,
|
||||
PERMS.RADIO_VIEW,
|
||||
PERMS.ASSETS_IMPORT,
|
||||
PERMS.ROOMS_EDIT,
|
||||
PERMS.ROOMS_DELETE,
|
||||
PERMS.RADIO_EDIT,
|
||||
PERMS.PAGES_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
@@ -94,6 +176,8 @@ const expectedManifests = [
|
||||
PERMS.NOTIFICATIONS_VIEW,
|
||||
PERMS.PERMISSIONS_MANAGE,
|
||||
PERMS.RCON_EXECUTE,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
PERMS.NOTIFICATIONS_EDIT,
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -240,10 +324,149 @@ describe("housekeeping registry", () => {
|
||||
descriptionKey: expected.descriptionKey,
|
||||
});
|
||||
expect(actual.routes).toEqual([]);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
expect(actual.widgets).toEqual([]);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
});
|
||||
|
||||
it("covers every capability attributed to each domain's migration rows", () => {
|
||||
for (const manifest of HOUSEKEEPING_MANIFESTS) {
|
||||
const attributedCapabilities = new Set(
|
||||
HOUSEKEEPING_MIGRATION_MATRIX.filter(
|
||||
(entry) => entry.targetDomain === manifest.id,
|
||||
).flatMap((entry) => [
|
||||
...entry.capabilities.read,
|
||||
...entry.capabilities.mutate,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const slug of attributedCapabilities) {
|
||||
expect(
|
||||
manifest.capability.slugs,
|
||||
`domain ${manifest.id}: ${slug}`,
|
||||
).toContain(slug);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate provider and widget ids across domain manifests", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||
{
|
||||
...manifest("content"),
|
||||
searchProviders: [searchProvider("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate search provider id: shared");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), inboxSources: [inboxSource("shared")] },
|
||||
{
|
||||
...manifest("content"),
|
||||
inboxSources: [inboxSource("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate inbox source id: shared");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), widgets: [widget("shared")] },
|
||||
{
|
||||
...manifest("content"),
|
||||
widgets: [widget("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate widget id: shared");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||
{
|
||||
...manifest("content"),
|
||||
widgets: [widget("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate widget id: shared");
|
||||
});
|
||||
|
||||
it("rejects provider and widget ownership outside their manifest", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
searchProviders: [searchProvider("people.users", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("search provider owner mismatch: people.users");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
inboxSources: [inboxSource("people.tickets", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("inbox source owner mismatch: people.tickets");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
widgets: [widget("people.queue", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("widget owner mismatch: people.queue");
|
||||
});
|
||||
|
||||
it("rejects empty ids and invalid capability shapes for registry metadata", () => {
|
||||
const invalidCapability = {
|
||||
mode: "some",
|
||||
slugs: [PERMS.USERS_VIEW],
|
||||
} as unknown as CapabilityRequirement;
|
||||
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider(" ")] },
|
||||
]),
|
||||
).toThrow("empty search provider id");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
inboxSources: [
|
||||
inboxSource("people.tickets", "people", invalidCapability),
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrow("invalid capability requirement");
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
widgets: [
|
||||
widget(
|
||||
"people.queue",
|
||||
"people",
|
||||
anyCapability("admin.ghost.widget"),
|
||||
),
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrow("unknown capability slug: admin.ghost.widget");
|
||||
});
|
||||
|
||||
it("rejects widget kinds outside the mandatory or optional contract", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{
|
||||
...manifest("people"),
|
||||
widgets: [
|
||||
{ ...widget("people.queue"), kind: "fixed" as "mandatory" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrow("invalid widget kind: people.queue");
|
||||
});
|
||||
|
||||
it("rejects empty domain and route capability requirements", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
|
||||
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||
interface OwnedRegistryEntry {
|
||||
id: string;
|
||||
owner: HousekeepingDomainManifest["id"];
|
||||
capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
export interface HousekeepingRegistry {
|
||||
domains: readonly HousekeepingDomainManifest[];
|
||||
@@ -18,6 +23,7 @@ export function createHousekeepingRegistry(
|
||||
const domainIds = new Set<string>();
|
||||
const routeIds = new Set<string>();
|
||||
const routeHrefs = new Set<string>();
|
||||
const registryEntryIds = new Set<string>();
|
||||
|
||||
for (const manifest of manifests) {
|
||||
if (!approvedDomainIds.has(manifest.id)) {
|
||||
@@ -34,6 +40,29 @@ export function createHousekeepingRegistry(
|
||||
validateNonEmpty(manifest.labelKey, "label key");
|
||||
validateNonEmpty(manifest.descriptionKey, "description key");
|
||||
validateCapability(manifest.capability);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.searchProviders,
|
||||
manifest.id,
|
||||
"search provider",
|
||||
registryEntryIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.inboxSources,
|
||||
manifest.id,
|
||||
"inbox source",
|
||||
registryEntryIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.widgets,
|
||||
manifest.id,
|
||||
"widget",
|
||||
registryEntryIds,
|
||||
);
|
||||
for (const widget of manifest.widgets) {
|
||||
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
||||
throw new Error(`invalid widget kind: ${widget.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of manifest.routes) {
|
||||
validateNonEmpty(route.id, "route id");
|
||||
@@ -56,18 +85,51 @@ export function createHousekeepingRegistry(
|
||||
return { domains: Object.freeze([...manifests]) };
|
||||
}
|
||||
|
||||
function validateOwnedRegistryEntries(
|
||||
entries: readonly OwnedRegistryEntry[],
|
||||
owner: HousekeepingDomainManifest["id"],
|
||||
kind: "search provider" | "inbox source" | "widget",
|
||||
ids: Set<string>,
|
||||
): void {
|
||||
for (const entry of entries) {
|
||||
validateNonEmpty(entry.id, `${kind} id`);
|
||||
if (ids.has(entry.id)) {
|
||||
throw new Error(`duplicate ${kind} id: ${entry.id}`);
|
||||
}
|
||||
ids.add(entry.id);
|
||||
|
||||
if (entry.owner !== owner) {
|
||||
throw new Error(`${kind} owner mismatch: ${entry.id}`);
|
||||
}
|
||||
|
||||
validateCapability(entry.capability);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNonEmpty(value: string, name: string): void {
|
||||
if (!value.trim()) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`empty ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCapability(requirement: CapabilityRequirement): void {
|
||||
if (
|
||||
typeof requirement !== "object" ||
|
||||
requirement === null ||
|
||||
(requirement.mode !== "any" && requirement.mode !== "all") ||
|
||||
!Array.isArray(requirement.slugs)
|
||||
) {
|
||||
throw new Error("invalid capability requirement");
|
||||
}
|
||||
|
||||
if (requirement.slugs.length === 0) {
|
||||
throw new Error("empty capability requirement");
|
||||
}
|
||||
|
||||
for (const slug of requirement.slugs) {
|
||||
if (typeof slug !== "string") {
|
||||
throw new Error("invalid capability requirement");
|
||||
}
|
||||
if (!knownCapabilitySlugs.has(slug)) {
|
||||
throw new Error(`unknown capability slug: ${slug}`);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,48 @@ describe("validateMigrationEntries", () => {
|
||||
expect(issues).toContain("REBUILD requires targetPath: /admin");
|
||||
});
|
||||
|
||||
it("rejects a discovery surface mismatch for the same legacy path", () => {
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
[page("/admin")],
|
||||
[{ ...entry("/admin"), surface: "mod" }],
|
||||
),
|
||||
).toEqual([
|
||||
"surface mismatch for legacyPath /admin: expected admin, received mod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a discovery source file mismatch for the same legacy path", () => {
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
[page("/admin")],
|
||||
[
|
||||
{
|
||||
...entry("/admin"),
|
||||
sourceFile: "src/app/admin/renamed/page.tsx",
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
"sourceFile mismatch for legacyPath /admin: expected src/app/admin/page.tsx, received src/app/admin/renamed/page.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects removal with a non-null target", () => {
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
[page("/admin")],
|
||||
[
|
||||
{
|
||||
...entry("/admin"),
|
||||
decision: "REMOVE",
|
||||
targetPath: "/admin/system/legacy",
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual(["REMOVE requires null targetPath: /admin"]);
|
||||
});
|
||||
|
||||
it("enforces migration safety evidence", () => {
|
||||
const issues = validateMigrationEntries(
|
||||
[page("/admin")],
|
||||
|
||||
@@ -16,7 +16,13 @@ export function validateMigrationEntries(
|
||||
entries: readonly MigrationEntry[],
|
||||
): string[] {
|
||||
const issues = new Set<string>();
|
||||
const discoveredPaths = new Set(discovered.map((page) => page.legacyPath));
|
||||
const discoveredByPath = new Map<string, LegacyPage[]>();
|
||||
for (const page of discovered) {
|
||||
const matchingPages = discoveredByPath.get(page.legacyPath) ?? [];
|
||||
matchingPages.push(page);
|
||||
discoveredByPath.set(page.legacyPath, matchingPages);
|
||||
}
|
||||
|
||||
const entriesByPath = new Map<string, MigrationEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -24,8 +30,30 @@ export function validateMigrationEntries(
|
||||
matchingEntries.push(entry);
|
||||
entriesByPath.set(entry.legacyPath, matchingEntries);
|
||||
|
||||
if (!discoveredPaths.has(entry.legacyPath)) {
|
||||
const matchingPages = discoveredByPath.get(entry.legacyPath);
|
||||
if (!matchingPages) {
|
||||
issues.add(`unknown legacyPath: ${entry.legacyPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const surfaceMatch = matchingPages.find(
|
||||
(page) => page.surface === entry.surface,
|
||||
);
|
||||
const identityMatch = matchingPages.some(
|
||||
(page) =>
|
||||
page.surface === entry.surface && page.sourceFile === entry.sourceFile,
|
||||
);
|
||||
if (!surfaceMatch) {
|
||||
const expectedSurface = matchingPages
|
||||
.map((page) => page.surface)
|
||||
.join(" or ");
|
||||
issues.add(
|
||||
`surface mismatch for legacyPath ${entry.legacyPath}: expected ${expectedSurface}, received ${entry.surface}`,
|
||||
);
|
||||
} else if (!identityMatch) {
|
||||
issues.add(
|
||||
`sourceFile mismatch for legacyPath ${entry.legacyPath}: expected ${surfaceMatch.sourceFile}, received ${entry.sourceFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +73,10 @@ export function validateMigrationEntries(
|
||||
for (const entry of entries) {
|
||||
const { legacyPath } = entry;
|
||||
|
||||
if (entry.decision === "REMOVE" && entry.targetPath !== null) {
|
||||
issues.add(`REMOVE requires null targetPath: ${legacyPath}`);
|
||||
}
|
||||
|
||||
if (entry.targetPath === null && entry.decision !== "REMOVE") {
|
||||
issues.add(`${entry.decision} requires targetPath: ${legacyPath}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user