diff --git a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx index ef7c55a..82c1fa1 100644 --- a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx @@ -2,22 +2,26 @@ import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; /** - * Covers the server-side access gate added by Plan 15-08 (D-07, PERM-04): + * Covers the server-side access gate added by Plan 15-08, restructured + * onto the shared ModuleAccessGate by 260907-e8k (D-07, PERM-04): * - * - ExpandedModulePage (page.tsx): renders the 403 markup and never the - * module shell when checkModuleAccess resolves false; renders the shell - * when it resolves true. + * - ExpandedModulePage (page.tsx): passes the route slug through to + * ModuleAccessGate unchanged and wraps the ModuleShell (with the same + * category and slug) as its child. The 403-vs-render decision itself + * now lives once in ModuleAccessGate and is covered by + * module-access-gate.test.tsx — not duplicated here. * - checkModuleAccess (module-access-actions.ts): fails closed (T-15-29) * on a missing session cookie and on a non-ok API response. * - ModuleShell: the whitelist check against MODULE_REGISTRY (T-15-30) * stays independent of the access check — an unregistered slug still * lands in the not-found state even when access was granted. * - * `@/lib/module-access-actions` and `./module-shell` are mocked per-test - * via vi.doMock (not a file-level vi.mock) because the page tests need - * them mocked, while the checkModuleAccess/ModuleShell tests need the - * real implementations — vi.resetModules() + vi.doUnmock() in afterEach - * keeps the two groups from leaking into each other. + * `@/components/modules/module-access-gate` and `./module-shell` are + * mocked per-test via vi.doMock (not a file-level vi.mock) because the + * page tests need them mocked, while the checkModuleAccess/ModuleShell + * tests need the real implementations — vi.resetModules() + + * vi.doUnmock() in afterEach keeps the two groups from leaking into + * each other. */ vi.mock('next-intl', () => ({ @@ -31,54 +35,24 @@ vi.mock('next-intl', () => ({ }, })); -vi.mock('next-intl/server', () => ({ - getTranslations: async () => (key: string) => { - const translations: Record = { - 'accessDenied.title': 'Kein Zugriff auf dieses Modul', - 'accessDenied.body': 'Du hast für dieses Modul keine Freigabe. Wende dich an deinen Administrator.', - 'accessDenied.backToDashboard': 'Zur Startseite', - }; - return translations[key] ?? key; - }, -})); - afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); vi.resetModules(); vi.doUnmock('./module-shell'); + vi.doUnmock('@/components/modules/module-access-gate'); vi.doUnmock('@/lib/module-access-actions'); vi.doUnmock('next/headers'); }); -describe('ExpandedModulePage — server access gate (D-07, PERM-04)', () => { - it('renders the 403 title and body and does not render the module shell when access is denied', async () => { - vi.doMock('@/lib/module-access-actions', () => ({ - checkModuleAccess: vi.fn().mockResolvedValue(false), - })); +describe('ExpandedModulePage — passes slug through to ModuleAccessGate (D-07, PERM-04)', () => { + it('renders a ModuleAccessGate with the route slug wrapping the ModuleShell', async () => { vi.doMock('./module-shell', () => ({ - ModuleShell: () =>
, - })); - - const { default: Page } = await import('./page'); - const element = await Page({ - params: Promise.resolve({ category: 'utilities', moduleSlug: 'domaincheck' }), - }); - render(element); - - expect(screen.getByText('Kein Zugriff auf dieses Modul')).toBeInTheDocument(); - expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument(); - expect(screen.queryByTestId('module-shell')).not.toBeInTheDocument(); - }); - - it('renders the module shell when access is granted', async () => { - vi.doMock('@/lib/module-access-actions', () => ({ - checkModuleAccess: vi.fn().mockResolvedValue(true), - })); - vi.doMock('./module-shell', () => ({ - ModuleShell: ({ moduleSlug }: { moduleSlug: string }) => ( -
{moduleSlug}
+ ModuleShell: ({ category, moduleSlug }: { category: string; moduleSlug: string }) => ( +
+ {category}/{moduleSlug} +
), })); @@ -86,10 +60,11 @@ describe('ExpandedModulePage — server access gate (D-07, PERM-04)', () => { const element = await Page({ params: Promise.resolve({ category: 'utilities', moduleSlug: 'domaincheck' }), }); - render(element); - expect(screen.queryByText('Kein Zugriff auf dieses Modul')).not.toBeInTheDocument(); - expect(screen.getByTestId('module-shell')).toHaveTextContent('domaincheck'); + expect(element.props.moduleSlug).toBe('domaincheck'); + + render(element.props.children); + expect(screen.getByTestId('module-shell')).toHaveTextContent('utilities/domaincheck'); }); }); diff --git a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx index bd4d2b1..fd32d01 100644 --- a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx @@ -1,6 +1,4 @@ -import { checkModuleAccess } from '@/lib/module-access-actions'; -import { getTranslations } from 'next-intl/server'; -import Link from 'next/link'; +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; import { ModuleShell } from './module-shell'; interface ExpandedModulePageProps { @@ -10,61 +8,26 @@ interface ExpandedModulePageProps { /** * Expanded module view — server-side access gate (D-07, PERM-04). * - * Server Component: reads the route params and calls checkModuleAccess - * before anything else renders. Sidebar, this page, and the module API - * all resolve access via the same ModuleAccessService function (D-01) — + * Server Component: reads the route params and hands the slug to the + * shared ModuleAccessGate, which resolves access via the same + * ModuleAccessService function the sidebar and module API use (D-01) — * the sidebar hiding an unfreigegeben module is convenience, not access * control. A direct URL hit or a bookmark still has to pass this check. * - * If access is not granted, this renders the 403 markup directly as the - * server response — no Next.js not-found routing and no redirect (D-07 - * explicit): the user should learn the module exists and they lack a - * grant, not be left thinking it doesn't exist or land silently elsewhere. + * The 403 markup and the fail-closed access check live once, in + * ModuleAccessGate — this route and the four module-owned layouts + * (cert-manager, dkv-fleet, domaincheck, tender-radar) all render the + * same gate (D-07, T-e8k-01). * * The registered-module whitelist and the actual module import stay in - * ModuleShell (client component) — unchanged behavior, just relocated. + * ModuleShell (client component) — unchanged behavior. */ export default async function ExpandedModulePage({ params }: ExpandedModulePageProps) { const { category, moduleSlug } = await params; - const t = await getTranslations('modules'); - const hasAccess = await checkModuleAccess(moduleSlug); - - if (!hasAccess) { - return ( -
-
-
- - - - -
-

{t('accessDenied.title')}

-

- {t('accessDenied.body')} -

- - {t('accessDenied.backToDashboard')} - -
-
- ); - } - - return ; + return ( + + + + ); } diff --git a/apps/web/src/app/(portal)/modules/cert-manager/layout.tsx b/apps/web/src/app/(portal)/modules/cert-manager/layout.tsx new file mode 100644 index 0000000..8ba778f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/layout.tsx @@ -0,0 +1,6 @@ +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; +import type { ReactNode } from 'react'; + +export default function CertManagerLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx new file mode 100644 index 0000000..2b42b0e --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx @@ -0,0 +1,6 @@ +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; +import type { ReactNode } from 'react'; + +export default function DkvFleetLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/domaincheck/layout.tsx b/apps/web/src/app/(portal)/modules/domaincheck/layout.tsx new file mode 100644 index 0000000..73acc17 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domaincheck/layout.tsx @@ -0,0 +1,6 @@ +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; +import type { ReactNode } from 'react'; + +export default function DomaincheckLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx new file mode 100644 index 0000000..82ee491 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx @@ -0,0 +1,56 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import CertManagerLayout from './cert-manager/layout'; +import DkvFleetLayout from './dkv-fleet/layout'; +import DomaincheckLayout from './domaincheck/layout'; +import TenderRadarLayout from './tender-radar/layout'; + +/** + * Covers T-e8k-01, T-e8k-03, and T-e8k-04 (WINDOWS #10, PERM-04): + * + * - Each module-owned layout wraps children in ModuleAccessGate with the + * slug that exactly matches its directory name — a copy-paste mistake + * between the four near-identical files would flip the wrong module's + * gate (T-e8k-03). + * - Every non-dynamic module directory has a layout.tsx — a future module + * directory added without one would run past the gate again exactly + * like the four routes this plan fixes (T-e8k-04). + */ + +const modulesDir = dirname(fileURLToPath(import.meta.url)); + +const placeholderChild =
child
; + +describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)', () => { + it.each([ + ['cert-manager', CertManagerLayout], + ['dkv-fleet', DkvFleetLayout], + ['domaincheck', DomaincheckLayout], + ['tender-radar', TenderRadarLayout], + ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { + const element = Layout({ children: placeholderChild }); + + expect(element.props.moduleSlug).toBe(expectedSlug); + expect(element.props.children).toBe(placeholderChild); + }); +}); + +describe('module directory coverage — every module has a layout (T-e8k-04)', () => { + it('requires a layout.tsx in every non-dynamic module directory', () => { + const entries = readdirSync(modulesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => !entry.name.startsWith('[')) + .map((entry) => entry.name); + + expect(entries.length).toBeGreaterThan(0); + + for (const dirName of entries) { + const hasLayout = + existsSync(join(modulesDir, dirName, 'layout.tsx')) || + existsSync(join(modulesDir, dirName, 'layout.ts')); + expect(hasLayout, `${dirName}/ is missing a layout.tsx`).toBe(true); + } + }); +}); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/layout.tsx b/apps/web/src/app/(portal)/modules/tender-radar/layout.tsx new file mode 100644 index 0000000..5a0c563 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/layout.tsx @@ -0,0 +1,6 @@ +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; +import type { ReactNode } from 'react'; + +export default function TenderRadarLayout({ children }: { children: ReactNode }) { + return {children}; +}