feat(15-08): serverseitige Modulsperre mit 403-Seite
- checkModuleAccess (module-access-actions.ts) fragt GET /modules/active serverseitig ab, Cookie-Weiterleitung nach fetchCurrentUser-Muster, schliesst im Zweifel (fehlendes Cookie, nicht-ok, Fehler -> false) - page.tsx zur async Server Component umgebaut, rendert bei fehlender Freigabe das 403-Markup direkt (kein notFound(), kein Redirect, D-07) - bisheriger Client-Inhalt (Whitelist-Pruefung, Nicht-gefunden-Zustand, Ruecknavigation) unveraendert nach module-shell.tsx ausgelagert - 5 Tests in module-access.test.tsx: 403-Zustand, Shell-Rendering, fehlendes Cookie, nicht-ok-Antwort, unregistrierter Slug trotz Zugriff
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
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):
|
||||||
|
*
|
||||||
|
* - ExpandedModulePage (page.tsx): renders the 403 markup and never the
|
||||||
|
* module shell when checkModuleAccess resolves false; renders the shell
|
||||||
|
* when it resolves true.
|
||||||
|
* - 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
notFound: 'Modul nicht gefunden',
|
||||||
|
notFoundDescription: 'Dieses Modul ist nicht registriert.',
|
||||||
|
backToCategory: 'Zurueck zur Kategorie',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doUnmock('./module-shell');
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
vi.doMock('./module-shell', () => ({
|
||||||
|
ModuleShell: () => <div data-testid="module-shell" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<div data-testid="module-shell">{moduleSlug}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: Page } = await import('./page');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkModuleAccess — fails closed (T-15-29)', () => {
|
||||||
|
it('returns false when there is no session cookie', async () => {
|
||||||
|
const mockGet = vi.fn().mockReturnValue(undefined);
|
||||||
|
vi.doMock('next/headers', () => ({
|
||||||
|
cookies: () => Promise.resolve({ get: mockGet }),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
|
||||||
|
const { checkModuleAccess } = await import('@/lib/module-access-actions');
|
||||||
|
const result = await checkModuleAccess('tender-radar');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the API responds with a non-ok status', async () => {
|
||||||
|
const mockGet = vi.fn().mockReturnValue({ value: 'session-abc' });
|
||||||
|
vi.doMock('next/headers', () => ({
|
||||||
|
cookies: () => Promise.resolve({ get: mockGet }),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||||
|
|
||||||
|
const { checkModuleAccess } = await import('@/lib/module-access-actions');
|
||||||
|
const result = await checkModuleAccess('tender-radar');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ModuleShell — whitelist stays independent of the access check (T-15-30)', () => {
|
||||||
|
it('shows the not-found state for an unregistered slug even when access is granted', async () => {
|
||||||
|
const { ModuleShell } = await import('./module-shell');
|
||||||
|
render(<ModuleShell category="utilities" moduleSlug="not-a-registered-module" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Modul nicht gefunden')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ModuleShellProps {
|
||||||
|
category: string;
|
||||||
|
moduleSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded module view — renders the full module UI via lazy loading.
|
||||||
|
*
|
||||||
|
* Per D-05b: expanded/full view when user opens a specific module.
|
||||||
|
* Uses loadModuleComponent to get the dynamically imported component.
|
||||||
|
*
|
||||||
|
* Security (T-03-09): Only slugs whitelisted in MODULE_REGISTRY are loaded.
|
||||||
|
* Arbitrary slugs from URL params result in a not-found state — no
|
||||||
|
* arbitrary imports are ever triggered.
|
||||||
|
*
|
||||||
|
* This is the client half of the module page split from Plan 15-08: the
|
||||||
|
* server half (page.tsx) already enforced access (D-07) before this
|
||||||
|
* component ever renders, so the whitelist here is a second, independent
|
||||||
|
* safeguard against arbitrary slugs — not a replacement for it.
|
||||||
|
*/
|
||||||
|
export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
|
||||||
|
const t = useTranslations('modules');
|
||||||
|
|
||||||
|
// Only load from the whitelist registry (T-03-09)
|
||||||
|
const isRegistered = moduleSlug in MODULE_REGISTRY;
|
||||||
|
const ModuleComponent = isRegistered
|
||||||
|
? loadModuleComponent(moduleSlug)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Not-found state for unknown/unregistered slugs
|
||||||
|
if (!ModuleComponent) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="rounded-lg bg-muted p-4 mb-4">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="m15 9-6 6" />
|
||||||
|
<path d="m9 9 6 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{t('notFoundDescription')}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/modules/${category}`}
|
||||||
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('backToCategory')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the lazily loaded module component
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<div className="px-6 pt-4">
|
||||||
|
<Link
|
||||||
|
href={`/modules/${category}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
</svg>
|
||||||
|
{t('backToCategory')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module content — loaded on demand (MOD-04) */}
|
||||||
|
<ModuleComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,36 @@
|
|||||||
'use client';
|
import { checkModuleAccess } from '@/lib/module-access-actions';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { ModuleShell } from './module-shell';
|
||||||
|
|
||||||
|
interface ExpandedModulePageProps {
|
||||||
|
params: Promise<{ category: string; moduleSlug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expanded module view — renders the full module UI via lazy loading.
|
* Expanded module view — server-side access gate (D-07, PERM-04).
|
||||||
*
|
*
|
||||||
* Per D-05b: expanded/full view when user opens a specific module.
|
* Server Component: reads the route params and calls checkModuleAccess
|
||||||
* Uses loadModuleComponent to get the dynamically imported component.
|
* before anything else renders. Sidebar, this page, and the module API
|
||||||
|
* all resolve access via the same ModuleAccessService function (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.
|
||||||
*
|
*
|
||||||
* Security (T-03-09): Only slugs whitelisted in MODULE_REGISTRY are loaded.
|
* If access is not granted, this renders the 403 markup directly as the
|
||||||
* Arbitrary slugs from URL params result in a not-found state — no
|
* server response — no Next.js not-found routing and no redirect (D-07
|
||||||
* arbitrary imports are ever triggered.
|
* 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 registered-module whitelist and the actual module import stay in
|
||||||
|
* ModuleShell (client component) — unchanged behavior, just relocated.
|
||||||
*/
|
*/
|
||||||
export default function ExpandedModulePage() {
|
export default async function ExpandedModulePage({ params }: ExpandedModulePageProps) {
|
||||||
const params = useParams<{ category: string; moduleSlug: string }>();
|
const { category, moduleSlug } = await params;
|
||||||
const { category, moduleSlug } = params;
|
const t = await getTranslations('modules');
|
||||||
const t = useTranslations('modules');
|
|
||||||
|
|
||||||
// Only load from the whitelist registry (T-03-09)
|
const hasAccess = await checkModuleAccess(moduleSlug);
|
||||||
const isRegistered = moduleSlug in MODULE_REGISTRY;
|
|
||||||
const ModuleComponent = isRegistered
|
|
||||||
? loadModuleComponent(moduleSlug)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Not-found state for unknown/unregistered slugs
|
if (!hasAccess) {
|
||||||
if (!ModuleComponent) {
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
@@ -44,55 +47,24 @@ export default function ExpandedModulePage() {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="m15 9-6 6" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
<path d="m9 9 6 6" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
|
<h2 className="text-xl font-semibold mb-2">{t('accessDenied.title')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
{t('notFoundDescription')}
|
{t('accessDenied.body')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/modules/${category}`}
|
href="/"
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{t('backToCategory')}
|
{t('accessDenied.backToDashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the lazily loaded module component
|
return <ModuleShell category={category} moduleSlug={moduleSlug} />;
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Back navigation */}
|
|
||||||
<div className="px-6 pt-4">
|
|
||||||
<Link
|
|
||||||
href={`/modules/${category}`}
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="m12 19-7-7 7-7" />
|
|
||||||
<path d="M19 12H5" />
|
|
||||||
</svg>
|
|
||||||
{t('backToCategory')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Module content — loaded on demand (MOD-04) */}
|
|
||||||
<ModuleComponent />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side module access check for the module page route (D-07, PERM-04).
|
||||||
|
*
|
||||||
|
* Reads the session cookie, forwards it to `GET /modules/active` (the same
|
||||||
|
* ModuleAccessService.getAccessibleModuleIds resolution ModuleGuard and the
|
||||||
|
* sidebar use — D-01), and checks whether `moduleSlug` is present in the
|
||||||
|
* response. Mirrors `fetchCurrentUser()` in auth-actions.ts exactly: same
|
||||||
|
* cookie-forwarding, `credentials: 'include'`, `cache: 'no-store'`.
|
||||||
|
*
|
||||||
|
* Fails closed (T-15-29): a missing session cookie, a non-ok API response,
|
||||||
|
* or a thrown network error all resolve to `false`. A broken network path
|
||||||
|
* must never open access.
|
||||||
|
*/
|
||||||
|
export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/modules/active`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `session=${session}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: Array<{ slug: string }> = await response.json();
|
||||||
|
return modules.some((module) => module.slug === moduleSlug);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user