From 43c7fa200b10d81fdfab8ac918ab2c3c2e8a88ac Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:41:25 +0200 Subject: [PATCH] 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 --- .../[moduleSlug]/module-access.test.tsx | 132 ++++++++++++++++++ .../[category]/[moduleSlug]/module-shell.tsx | 105 ++++++++++++++ .../modules/[category]/[moduleSlug]/page.tsx | 92 +++++------- apps/web/src/lib/module-access-actions.ts | 46 ++++++ 4 files changed, 315 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx create mode 100644 apps/web/src/lib/module-access-actions.ts 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 new file mode 100644 index 0000000..ef7c55a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx @@ -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 = { + 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 = { + '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: () =>
, + })); + + 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}
+ ), + })); + + 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(); + + expect(screen.getByText('Modul nicht gefunden')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx new file mode 100644 index 0000000..cf58834 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx @@ -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 ( +
+
+
+ + + + + +
+

{t('notFound')}

+

+ {t('notFoundDescription')} +

+ + {t('backToCategory')} + +
+
+ ); + } + + // Render the lazily loaded module component + return ( +
+ {/* Back navigation */} +
+ + + + + + {t('backToCategory')} + +
+ + {/* Module content — loaded on demand (MOD-04) */} + +
+ ); +} 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 702636e..bd4d2b1 100644 --- a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx @@ -1,33 +1,36 @@ -'use client'; - -import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader'; -import { useTranslations } from 'next-intl'; +import { checkModuleAccess } from '@/lib/module-access-actions'; +import { getTranslations } from 'next-intl/server'; 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. - * Uses loadModuleComponent to get the dynamically imported component. + * 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) — + * 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. - * Arbitrary slugs from URL params result in a not-found state — no - * arbitrary imports are ever triggered. + * 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 registered-module whitelist and the actual module import stay in + * ModuleShell (client component) — unchanged behavior, just relocated. */ -export default function ExpandedModulePage() { - const params = useParams<{ category: string; moduleSlug: string }>(); - const { category, moduleSlug } = params; - const t = useTranslations('modules'); +export default async function ExpandedModulePage({ params }: ExpandedModulePageProps) { + const { category, moduleSlug } = await params; + const t = await getTranslations('modules'); - // Only load from the whitelist registry (T-03-09) - const isRegistered = moduleSlug in MODULE_REGISTRY; - const ModuleComponent = isRegistered - ? loadModuleComponent(moduleSlug) - : null; + const hasAccess = await checkModuleAccess(moduleSlug); - // Not-found state for unknown/unregistered slugs - if (!ModuleComponent) { + if (!hasAccess) { return (
@@ -44,55 +47,24 @@ export default function ExpandedModulePage() { strokeLinejoin="round" className="text-muted-foreground" > - - - + +
-

{t('notFound')}

+

{t('accessDenied.title')}

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

- {t('backToCategory')} + {t('accessDenied.backToDashboard')}
); } - // Render the lazily loaded module component - return ( -
- {/* Back navigation */} -
- - - - - - {t('backToCategory')} - -
- - {/* Module content — loaded on demand (MOD-04) */} - -
- ); + return ; } diff --git a/apps/web/src/lib/module-access-actions.ts b/apps/web/src/lib/module-access-actions.ts new file mode 100644 index 0000000..bfcff50 --- /dev/null +++ b/apps/web/src/lib/module-access-actions.ts @@ -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 { + 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; + } +}