From 65cb3008ee28a2d2da63487a18f6104d6897c5f4 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 19:21:52 +0000 Subject: [PATCH] feat(admin): shell back-office rendu serveur + garde de page (P3) AdminController : base des pages back-office. guard(permission?) applique RG-6/RG-T02 (302 vers /login si session absente/expiree/inactive) puis RG-T03 (403 si permission manquante), sinon renvoie la GuardResult ; adminView() rend dans le shell admin en injectant identite + permissions + jeton CSRF. Controller gagne un hook layoutName() (defaut inchange). DashboardController -> GET /admin/dashboard (landing authentifie ; KPI reels = chunk stats). UserDirectory : nom + libelle de role pour la topbar. Vues admin/{layout,dashboard,forbidden} : navigation conditionnee aux permissions, logout en POST CSRF, sorties echappees (RG-T15), assets en chemins absolus. Premier cablage de SessionGuard sur une page. 127 tests (dont 403 garde, echappement XSS), PHPStan L6. --- src/app/Auth/UserDirectory.php | 40 ++++ src/app/Controllers/AdminController.php | 79 +++++++ src/app/Controllers/DashboardController.php | 34 +++ src/app/Core/Controller.php | 16 +- src/app/Views/admin/dashboard.php | 26 +++ src/app/Views/admin/forbidden.php | 19 ++ src/app/Views/admin/layout.php | 137 ++++++++++++ src/public/admin/index.php | 4 + tests/Support/FakeDatabase.php | 13 ++ tests/Unit/Admin/DashboardControllerTest.php | 208 +++++++++++++++++++ tests/Unit/Auth/UserDirectoryTest.php | 46 ++++ 11 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 src/app/Auth/UserDirectory.php create mode 100644 src/app/Controllers/AdminController.php create mode 100644 src/app/Controllers/DashboardController.php create mode 100644 src/app/Views/admin/dashboard.php create mode 100644 src/app/Views/admin/forbidden.php create mode 100644 src/app/Views/admin/layout.php create mode 100644 tests/Unit/Admin/DashboardControllerTest.php create mode 100644 tests/Unit/Auth/UserDirectoryTest.php diff --git a/src/app/Auth/UserDirectory.php b/src/app/Auth/UserDirectory.php new file mode 100644 index 0000000..0a61da0 --- /dev/null +++ b/src/app/Auth/UserDirectory.php @@ -0,0 +1,40 @@ +db->fetch( + 'SELECT u.first_name, u.last_name, r.label AS role_label ' + . 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id', + ['id' => $userId], + ); + + $first = is_string($row['first_name'] ?? null) ? $row['first_name'] : ''; + $last = is_string($row['last_name'] ?? null) ? $row['last_name'] : ''; + $name = trim($first . ' ' . $last); + + return [ + 'name' => $name !== '' ? $name : 'Utilisateur', + 'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '', + ]; + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php new file mode 100644 index 0000000..130c0d2 --- /dev/null +++ b/src/app/Controllers/AdminController.php @@ -0,0 +1,79 @@ +sessionGuard()->check(); + + if (!$result->authenticated || $result->userId === null || $result->roleId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + if ($permission !== null && !$this->authorizer()->can($result->roleId, $permission)) { + return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => ''], $result, 403); + } + + return $result; + } + + /** + * Rend une vue dans le shell admin en injectant le contexte commun + * (nom/role de l'utilisateur, permissions pour la navigation, jeton CSRF). + * Les cles passees dans $data ont priorite (ex. activeNav). + * + * @param array $data + */ + protected function adminView(string $name, array $data, GuardResult $guard, int $status = 200): Response + { + $userId = $guard->userId ?? 0; + $roleId = $guard->roleId ?? 0; + $info = $this->userDirectory()->displayInfo($userId); + + $context = [ + 'currentUserName' => $info['name'], + 'currentUserRole' => $info['role_label'], + 'permissions' => $this->authorizer()->permissionsFor($roleId), + 'csrfToken' => Csrf::token($this->sessionManager()), + 'activeNav' => '', + ]; + + return $this->view($name, $data + $context, $status); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->database); + } +} diff --git a/src/app/Controllers/DashboardController.php b/src/app/Controllers/DashboardController.php new file mode 100644 index 0000000..e85511c --- /dev/null +++ b/src/app/Controllers/DashboardController.php @@ -0,0 +1,34 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView( + 'admin/dashboard', + ['title' => 'Tableau de bord - Wakdo Admin', 'activeNav' => 'dashboard'], + $guard, + ); + } +} diff --git a/src/app/Core/Controller.php b/src/app/Core/Controller.php index b2bab84..dc05746 100644 --- a/src/app/Core/Controller.php +++ b/src/app/Core/Controller.php @@ -7,8 +7,9 @@ namespace App\Core; use RuntimeException; /** - * Controleur de base. Toute la hierarchie de controleurs en herite - * (BaseController -> ProductController, etc., demonstration heritage Cr 4.c.1). + * Controleur de base. Toute la hierarchie de controleurs en herite (demonstration + * heritage Cr 4.c.1) : Controller -> AuthenticatedController -> AdminController -> + * DashboardController (et les futurs CRUD), ou directement HomeController / HealthController. * * Recoit ses dependances par constructeur : la requete courante, la config et * l'acces BDD, injectes par le Router. @@ -41,11 +42,20 @@ abstract class Controller protected function view(string $name, array $data = [], int $status = 200): Response { $content = $this->render($name, $data); - $html = $this->render('layout', $data + ['content' => $content]); + $html = $this->render($this->layoutName(), $data + ['content' => $content]); return (new Response())->html($html, $status); } + /** + * Gabarit enveloppant les vues. Defaut : le layout minimal. Les controleurs + * back-office surchargent ce hook pour rendre dans le shell admin. + */ + protected function layoutName(): string + { + return 'layout'; + } + /** * @param array $data */ diff --git a/src/app/Views/admin/dashboard.php b/src/app/Views/admin/dashboard.php new file mode 100644 index 0000000..50f9e89 --- /dev/null +++ b/src/app/Views/admin/dashboard.php @@ -0,0 +1,26 @@ + + + +
+

Le back-office est en ligne. Utilisez la navigation pour gerer le catalogue, + les commandes et les utilisateurs selon vos permissions.

+

Les indicateurs (ventes, commandes du jour) seront ajoutes prochainement.

+
diff --git a/src/app/Views/admin/forbidden.php b/src/app/Views/admin/forbidden.php new file mode 100644 index 0000000..b9bfa91 --- /dev/null +++ b/src/app/Views/admin/forbidden.php @@ -0,0 +1,19 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php new file mode 100644 index 0000000..8f2695c --- /dev/null +++ b/src/app/Views/admin/layout.php @@ -0,0 +1,137 @@ + $permissions + * @var string $csrfToken + * @var string $activeNav + */ + +$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8'); +$userName = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8'); +$userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$active = is_string($activeNav ?? null) ? $activeNav : ''; + +/** @var list $perms */ +$perms = isset($permissions) && is_array($permissions) ? $permissions : []; +$can = static fn (string $code): bool => in_array($code, $perms, true); + +// Initiales pour l'avatar (2 lettres max), derivees du nom affiche. Fonctions +// multibyte (UTF-8) : un prenom a initiale accentuee (frequent en francais) doit +// produire une lettre valide, pas un octet de tete isole qui viderait l'echappement. +$initials = ''; +foreach (preg_split('/\s+/', trim((string) ($currentUserName ?? ''))) ?: [] as $word) { + if ($word !== '' && mb_strlen($initials, 'UTF-8') < 2) { + $initials .= mb_strtoupper(mb_substr($word, 0, 1, 'UTF-8'), 'UTF-8'); + } +} +$initials = $initials !== '' ? $initials : 'U'; + +/** + * @param string $code cle de nav active + * @param string $current + */ +$navClass = static function (string $code, string $current): string { + return $code === $current ? 'sidebar-item active' : 'sidebar-item'; +}; +?> + + + + + + <?= $pageTitle ?> + + + +
+
+ + +
+
+ + +
+
+
+ + + +
+ +
+
+ + + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index bf6307d..027f9f8 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -12,6 +12,7 @@ declare(strict_types=1); use App\Auth\SessionManager; use App\Controllers\AuthController; +use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; @@ -61,6 +62,9 @@ try { // RBAC : identite + permissions de la session courante (gardee par SessionGuard). $router->add('GET', '/api/me', [MeController::class, 'show']); + // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. + $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index f6ad7d2..a68841e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -90,6 +90,13 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $pinUserRow = null; + /** + * Ligne renvoyee pour UserDirectory::displayInfo (nom + libelle role) ; null = absent. + * + * @var array|null + */ + public ?array $userDisplayRow = null; + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ public ?RuntimeException $failOnExecute = null; @@ -103,6 +110,12 @@ final class FakeDatabase implements DatabaseInterface { $this->reads[] = ['sql' => $sql, 'params' => $params]; + // Doit passer AVANT le lookup auth : la requete displayInfo contient aussi + // 'FROM user u JOIN role' mais selectionne 'AS role_label'. + if (str_contains($sql, 'AS role_label')) { + return $this->userDisplayRow; + } + if (str_contains($sql, 'FROM user u JOIN role')) { return $this->userRow; } diff --git a/tests/Unit/Admin/DashboardControllerTest.php b/tests/Unit/Admin/DashboardControllerTest.php new file mode 100644 index 0000000..e161e8d --- /dev/null +++ b/tests/Unit/Admin/DashboardControllerTest.php @@ -0,0 +1,208 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->fakeDb); + } + + /** + * Expose le chemin garde par permission d'AdminController::guard() (RG-T03), + * que le dashboard (auth seule) n'exerce pas. + */ + public function gated(): GuardResult|Response + { + $guard = $this->guard('user.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/dashboard', ['title' => 't', 'activeNav' => ''], $guard); + } +} + +final class DashboardControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function controller(SessionManager $session, FakeDatabase $db): TestDashboardController + { + $request = new Request('GET', '/admin/dashboard', [], [], '', '203.0.113.5'); + + return new TestDashboardController($request, new Config(), new Database(new Config()), $session, $db); + } + + private function authedSession(): SessionManager + { + $session = new SessionManager(new Config(), true); + $now = time(); + $session->set('user_id', 1); + $session->set('role_id', 1); + $session->set('logged_in_at', $now - 100); + $session->set('last_activity', $now - 50); + + return $session; + } + + public function testRedirectsToLoginWithoutSession(): void + { + $response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->index(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + } + + public function testInactiveUserRedirectsToLogin(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 0]; + + $response = $this->controller($this->authedSession(), $db)->index(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + } + + public function testRendersShellWhenAuthenticated(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->permissionCodes = ['product.read', 'user.read']; + + $response = $this->controller($this->authedSession(), $db)->index(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + // Shell rendu (topbar/sidebar) + identite + page. + self::assertStringContainsString('admin-layout', $body); + self::assertStringContainsString('Tableau de bord', $body); + self::assertStringContainsString('Corentin J', $body); + self::assertStringContainsString('Administrateur', $body); + // Marqueur present UNIQUEMENT dans le fragment dashboard (absent du layout) : + // verifie que le contenu est bien compose DANS le shell (pas un $content vide). + self::assertStringContainsString('Bienvenue, Corentin J', $body); + // Navigation conditionnee aux permissions. + self::assertStringContainsString('/admin/products', $body); // product.read present + self::assertStringContainsString('/admin/users', $body); // user.read present + self::assertStringNotContainsString('/admin/roles', $body); // role.manage absent + // Deconnexion = formulaire POST avec CSRF. + self::assertStringContainsString('action="/logout"', $body); + self::assertStringContainsString('name="_csrf"', $body); + } + + public function testForbiddenWhenPermissionDenied(): void + { + // Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden. + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Equipier']; + $db->canResult = false; + + $response = $this->controller($this->authedSession(), $db)->gated(); + + self::assertSame(403, $response->status()); + self::assertStringContainsString('Acces refuse', $response->body()); + } + + public function testGatedPageRendersWhenPermitted(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['user.read']; + + $response = $this->controller($this->authedSession(), $db)->gated(); + + self::assertSame(200, $response->status()); + } + + public function testEscapesUserIdentity(): void + { + // Donnees user-editables (nom/role) : doivent etre echappees (RG-T15). + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = [ + 'first_name' => '', + 'last_name' => 'x', + 'role_label' => 'Admin & co', + ]; + $db->permissionCodes = ['user.read']; + + $body = $this->controller($this->authedSession(), $db)->index()->body(); + + self::assertStringContainsString('<script>', $body); + self::assertStringNotContainsString('', $body); + self::assertStringContainsString('& co', $body); + self::assertStringNotContainsString('Admin ', $body); + } +} diff --git a/tests/Unit/Auth/UserDirectoryTest.php b/tests/Unit/Auth/UserDirectoryTest.php new file mode 100644 index 0000000..be0b993 --- /dev/null +++ b/tests/Unit/Auth/UserDirectoryTest.php @@ -0,0 +1,46 @@ +db = new FakeDatabase(); + } + + public function testDisplayInfoReturnsNameAndRoleLabel(): void + { + $this->db->userDisplayRow = [ + 'first_name' => 'Corentin', + 'last_name' => 'J', + 'role_label' => 'Administrateur', + ]; + + self::assertSame( + ['name' => 'Corentin J', 'role_label' => 'Administrateur'], + (new UserDirectory($this->db))->displayInfo(7), + ); + } + + public function testDisplayInfoDefaultsWhenAbsent(): void + { + $this->db->userDisplayRow = null; + + self::assertSame( + ['name' => 'Utilisateur', 'role_label' => ''], + (new UserDirectory($this->db))->displayInfo(999), + ); + } +} -- 2.45.3