feat(quick-260907-e8k-02): Layout-Gate vor jedes Modulverzeichnis, generische Route umgestellt
Legt je ein layout.tsx fuer cert-manager, dkv-fleet, domaincheck und tender-radar an, das ModuleAccessGate mit dem fest eingetragenen Verzeichnis-Slug umschliesst. Ein Layout im App Router deckt alle verschachtelten Unterrouten automatisch mit ab — my-sources und settings unter tender-radar sowie settings und vehicles unter dkv-fleet schliessen sich ohne eigene Datei (WINDOWS #10, PERM-04). Die generische Route [category]/[moduleSlug]/page.tsx nutzt jetzt ebenfalls ModuleAccessGate statt des bisherigen Inline-403-Markups — das 403-Markup existiert damit nur noch einmal im Code (module-access-denied.tsx). module-layouts.test.tsx deckt zwei Threats ab: falscher Slug in einem der vier Layouts (T-e8k-03) und ein kuenftig hinzugefuegtes Modulverzeichnis ohne Layout (T-e8k-04, liest das Verzeichnis per node:fs aus). module-access.test.tsx ist auf die Weitergabe an das Gate umgeschrieben, die 403-vs-Rendern-Entscheidung ist bereits durch module-access-gate.test.tsx abgedeckt. Volle Web-Testsuite (213 Tests), Typpruefung und Produktionsbau sind gruen. Browser-Gegenprobe folgt durch den Orchestrator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
This commit is contained in:
@@ -2,22 +2,26 @@ import { cleanup, render, screen } from '@testing-library/react';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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
|
* - ExpandedModulePage (page.tsx): passes the route slug through to
|
||||||
* module shell when checkModuleAccess resolves false; renders the shell
|
* ModuleAccessGate unchanged and wraps the ModuleShell (with the same
|
||||||
* when it resolves true.
|
* 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)
|
* - checkModuleAccess (module-access-actions.ts): fails closed (T-15-29)
|
||||||
* on a missing session cookie and on a non-ok API response.
|
* on a missing session cookie and on a non-ok API response.
|
||||||
* - ModuleShell: the whitelist check against MODULE_REGISTRY (T-15-30)
|
* - ModuleShell: the whitelist check against MODULE_REGISTRY (T-15-30)
|
||||||
* stays independent of the access check — an unregistered slug still
|
* stays independent of the access check — an unregistered slug still
|
||||||
* lands in the not-found state even when access was granted.
|
* lands in the not-found state even when access was granted.
|
||||||
*
|
*
|
||||||
* `@/lib/module-access-actions` and `./module-shell` are mocked per-test
|
* `@/components/modules/module-access-gate` and `./module-shell` are
|
||||||
* via vi.doMock (not a file-level vi.mock) because the page tests need
|
* mocked per-test via vi.doMock (not a file-level vi.mock) because the
|
||||||
* them mocked, while the checkModuleAccess/ModuleShell tests need the
|
* page tests need them mocked, while the checkModuleAccess/ModuleShell
|
||||||
* real implementations — vi.resetModules() + vi.doUnmock() in afterEach
|
* tests need the real implementations — vi.resetModules() +
|
||||||
* keeps the two groups from leaking into each other.
|
* vi.doUnmock() in afterEach keeps the two groups from leaking into
|
||||||
|
* each other.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
@@ -31,54 +35,24 @@ vi.mock('next-intl', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next-intl/server', () => ({
|
|
||||||
getTranslations: async () => (key: string) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
'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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doUnmock('./module-shell');
|
vi.doUnmock('./module-shell');
|
||||||
|
vi.doUnmock('@/components/modules/module-access-gate');
|
||||||
vi.doUnmock('@/lib/module-access-actions');
|
vi.doUnmock('@/lib/module-access-actions');
|
||||||
vi.doUnmock('next/headers');
|
vi.doUnmock('next/headers');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ExpandedModulePage — server access gate (D-07, PERM-04)', () => {
|
describe('ExpandedModulePage — passes slug through to ModuleAccessGate (D-07, PERM-04)', () => {
|
||||||
it('renders the 403 title and body and does not render the module shell when access is denied', async () => {
|
it('renders a ModuleAccessGate with the route slug wrapping the ModuleShell', async () => {
|
||||||
vi.doMock('@/lib/module-access-actions', () => ({
|
|
||||||
checkModuleAccess: vi.fn().mockResolvedValue(false),
|
|
||||||
}));
|
|
||||||
vi.doMock('./module-shell', () => ({
|
vi.doMock('./module-shell', () => ({
|
||||||
ModuleShell: () => <div data-testid="module-shell" />,
|
ModuleShell: ({ category, moduleSlug }: { category: string; moduleSlug: string }) => (
|
||||||
}));
|
<div data-testid="module-shell">
|
||||||
|
{category}/{moduleSlug}
|
||||||
const { default: Page } = await import('./page');
|
</div>
|
||||||
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 }) => (
|
|
||||||
<div data-testid="module-shell">{moduleSlug}</div>
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -86,10 +60,11 @@ describe('ExpandedModulePage — server access gate (D-07, PERM-04)', () => {
|
|||||||
const element = await Page({
|
const element = await Page({
|
||||||
params: Promise.resolve({ category: 'utilities', moduleSlug: 'domaincheck' }),
|
params: Promise.resolve({ category: 'utilities', moduleSlug: 'domaincheck' }),
|
||||||
});
|
});
|
||||||
render(element);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Kein Zugriff auf dieses Modul')).not.toBeInTheDocument();
|
expect(element.props.moduleSlug).toBe('domaincheck');
|
||||||
expect(screen.getByTestId('module-shell')).toHaveTextContent('domaincheck');
|
|
||||||
|
render(element.props.children);
|
||||||
|
expect(screen.getByTestId('module-shell')).toHaveTextContent('utilities/domaincheck');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { checkModuleAccess } from '@/lib/module-access-actions';
|
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { ModuleShell } from './module-shell';
|
import { ModuleShell } from './module-shell';
|
||||||
|
|
||||||
interface ExpandedModulePageProps {
|
interface ExpandedModulePageProps {
|
||||||
@@ -10,61 +8,26 @@ interface ExpandedModulePageProps {
|
|||||||
/**
|
/**
|
||||||
* Expanded module view — server-side access gate (D-07, PERM-04).
|
* Expanded module view — server-side access gate (D-07, PERM-04).
|
||||||
*
|
*
|
||||||
* Server Component: reads the route params and calls checkModuleAccess
|
* Server Component: reads the route params and hands the slug to the
|
||||||
* before anything else renders. Sidebar, this page, and the module API
|
* shared ModuleAccessGate, which resolves access via the same
|
||||||
* all resolve access via the same ModuleAccessService function (D-01) —
|
* ModuleAccessService function the sidebar and module API use (D-01) —
|
||||||
* the sidebar hiding an unfreigegeben module is convenience, not access
|
* the sidebar hiding an unfreigegeben module is convenience, not access
|
||||||
* control. A direct URL hit or a bookmark still has to pass this check.
|
* 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
|
* The 403 markup and the fail-closed access check live once, in
|
||||||
* server response — no Next.js not-found routing and no redirect (D-07
|
* ModuleAccessGate — this route and the four module-owned layouts
|
||||||
* explicit): the user should learn the module exists and they lack a
|
* (cert-manager, dkv-fleet, domaincheck, tender-radar) all render the
|
||||||
* grant, not be left thinking it doesn't exist or land silently elsewhere.
|
* same gate (D-07, T-e8k-01).
|
||||||
*
|
*
|
||||||
* The registered-module whitelist and the actual module import stay in
|
* 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) {
|
export default async function ExpandedModulePage({ params }: ExpandedModulePageProps) {
|
||||||
const { category, moduleSlug } = await params;
|
const { category, moduleSlug } = await params;
|
||||||
const t = await getTranslations('modules');
|
|
||||||
|
|
||||||
const hasAccess = await checkModuleAccess(moduleSlug);
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
<ModuleAccessGate moduleSlug={moduleSlug}>
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<ModuleShell category={category} moduleSlug={moduleSlug} />
|
||||||
<div className="rounded-lg bg-muted p-4 mb-4">
|
</ModuleAccessGate>
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">{t('accessDenied.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
|
||||||
{t('accessDenied.body')}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t('accessDenied.backToDashboard')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <ModuleShell category={category} moduleSlug={moduleSlug} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <ModuleAccessGate moduleSlug="cert-manager">{children}</ModuleAccessGate>;
|
||||||
|
}
|
||||||
@@ -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 <ModuleAccessGate moduleSlug="dkv-fleet">{children}</ModuleAccessGate>;
|
||||||
|
}
|
||||||
@@ -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 <ModuleAccessGate moduleSlug="domaincheck">{children}</ModuleAccessGate>;
|
||||||
|
}
|
||||||
@@ -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 = <div data-testid="placeholder-child">child</div>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <ModuleAccessGate moduleSlug="tender-radar">{children}</ModuleAccessGate>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user