feat: shell back-office P3 (pages rendues serveur + garde) #14
11 changed files with 619 additions and 3 deletions
40
src/app/Auth/UserDirectory.php
Normal file
40
src/app/Auth/UserDirectory.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Lecture des informations d'affichage d'un utilisateur (nom + libelle de role)
|
||||
* pour l'entete du back-office. Separe d'Authorizer (qui ne traite que les
|
||||
* permissions) ; depend de DatabaseInterface pour rester testable avec un double.
|
||||
*/
|
||||
final class UserDirectory
|
||||
{
|
||||
public function __construct(private readonly DatabaseInterface $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name: string, role_label: string}
|
||||
*/
|
||||
public function displayInfo(int $userId): array
|
||||
{
|
||||
$row = $this->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'] : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
79
src/app/Controllers/AdminController.php
Normal file
79
src/app/Controllers/AdminController.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\GuardResult;
|
||||
use App\Auth\UserDirectory;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Base des pages back-office rendues serveur (P3). Etend AuthenticatedController
|
||||
* (session + autorisation) et :
|
||||
* - rend dans le shell admin (topbar + sidebar) via layoutName(),
|
||||
* - fournit guard() : applique RG-6/RG-T02 (redirige vers /login si non
|
||||
* authentifie) puis RG-T03 (403 si la permission manque), sinon renvoie la
|
||||
* GuardResult,
|
||||
* - injecte le contexte commun du layout (utilisateur, role, permissions, CSRF).
|
||||
*
|
||||
* Non `final` : les controleurs concrets (Dashboard, Category...) en heritent ;
|
||||
* les tests sous-classent pour injecter des doubles.
|
||||
*/
|
||||
abstract class AdminController extends AuthenticatedController
|
||||
{
|
||||
protected function layoutName(): string
|
||||
{
|
||||
return 'admin/layout';
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde de page : 302 vers /login si la session est absente/expiree/inactive ;
|
||||
* 403 (page admin) si $permission est exigee et non detenue ; sinon la
|
||||
* GuardResult authentifiee. L'appelant fait : if ($g instanceof Response) return $g;
|
||||
*/
|
||||
protected function guard(?string $permission = null): GuardResult|Response
|
||||
{
|
||||
$result = $this->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
34
src/app/Controllers/DashboardController.php
Normal file
34
src/app/Controllers/DashboardController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Tableau de bord back-office. GET /admin/dashboard (landing par defaut du role
|
||||
* admin, cf. seed role.default_route). Accessible a tout utilisateur authentifie ;
|
||||
* les KPI reels (stats.read) seront ajoutes au chunk statistiques.
|
||||
*
|
||||
* Non `final` : les tests sous-classent pour injecter des doubles via les hooks.
|
||||
*/
|
||||
class DashboardController extends AdminController
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, mixed> $data
|
||||
*/
|
||||
|
|
|
|||
26
src/app/Views/admin/dashboard.php
Normal file
26
src/app/Views/admin/dashboard.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragment du tableau de bord, injecte dans admin/layout.php. Volontairement
|
||||
* minimal en chunk shell : les KPI reels (ventes, commandes) viendront avec le
|
||||
* chunk statistiques (permission stats.read).
|
||||
*
|
||||
* @var string $currentUserName
|
||||
*/
|
||||
|
||||
$name = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Tableau de bord</h1>
|
||||
<p class="page-subtitle">Bienvenue, <?= $name ?>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<p>Le back-office est en ligne. Utilisez la navigation pour gerer le catalogue,
|
||||
les commandes et les utilisateurs selon vos permissions.</p>
|
||||
<p><small>Les indicateurs (ventes, commandes du jour) seront ajoutes prochainement.</small></p>
|
||||
</section>
|
||||
19
src/app/Views/admin/forbidden.php
Normal file
19
src/app/Views/admin/forbidden.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragment 403, injecte dans admin/layout.php : l'utilisateur est authentifie
|
||||
* mais ne detient pas la permission requise (RG-T03).
|
||||
*/
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Acces refuse</h1>
|
||||
<p class="page-subtitle">Vous n'avez pas la permission d'acceder a cette page.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<p><a href="/admin/dashboard">Retour au tableau de bord</a></p>
|
||||
</section>
|
||||
137
src/app/Views/admin/layout.php
Normal file
137
src/app/Views/admin/layout.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Shell du back-office (topbar + sidebar + zone de contenu), reutilise par toutes
|
||||
* les pages admin rendues serveur. Recoit le contenu de la page et le contexte
|
||||
* commun injecte par AdminController::adminView().
|
||||
*
|
||||
* Chemins d'assets ABSOLUS (/assets/...) : les pages sont servies sous des routes
|
||||
* /admin/... alors que les fichiers vivent a la racine du docroot du vhost admin ;
|
||||
* un chemin relatif resoudrait vers /admin/assets/... (404).
|
||||
*
|
||||
* @var string $title
|
||||
* @var string $content
|
||||
* @var string $currentUserName
|
||||
* @var string $currentUserRole
|
||||
* @var list<string> $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<string> $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';
|
||||
};
|
||||
?><!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $pageTitle ?></title>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo">
|
||||
<img src="/assets/images/logo.png" alt="Wakdo">
|
||||
<div>
|
||||
<span class="topbar-logo-text">Wakdo</span>
|
||||
<span class="topbar-logo-sub">Administration</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<div class="topbar-user">
|
||||
<button class="topbar-user-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
<div class="topbar-user-avatar"><?= htmlspecialchars($initials, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div>
|
||||
<div class="topbar-user-name"><?= $userName ?></div>
|
||||
<div class="topbar-user-role"><?= $userRole ?></div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="dropdown-menu" id="userMenu">
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
<button class="danger" type="submit">Se deconnecter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Vue d'ensemble</div>
|
||||
<a href="/admin/dashboard" class="<?= $navClass('dashboard', $active) ?>">Tableau de bord</a>
|
||||
</div>
|
||||
|
||||
<?php if ($can('product.read') || $can('menu.read') || $can('category.manage')): ?>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Catalogue</div>
|
||||
<?php if ($can('category.manage')): ?>
|
||||
<a href="/admin/categories" class="<?= $navClass('categories', $active) ?>">Categories</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($can('product.read')): ?>
|
||||
<a href="/admin/products" class="<?= $navClass('products', $active) ?>">Produits</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($can('menu.read')): ?>
|
||||
<a href="/admin/menus" class="<?= $navClass('menus', $active) ?>">Menus</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($can('order.read')): ?>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Operations</div>
|
||||
<a href="/admin/orders" class="<?= $navClass('orders', $active) ?>">Commandes</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($can('user.read') || $can('role.manage')): ?>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Administration</div>
|
||||
<?php if ($can('user.read')): ?>
|
||||
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($can('role.manage')): ?>
|
||||
<a href="/admin/roles" class="<?= $navClass('roles', $active) ?>">Roles</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<?= $content ?? '' ?>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, mixed>|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;
|
||||
}
|
||||
|
|
|
|||
208
tests/Unit/Admin/DashboardControllerTest.php
Normal file
208
tests/Unit/Admin/DashboardControllerTest.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Authorizer;
|
||||
use App\Auth\GuardResult;
|
||||
use App\Auth\SessionGuard;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Auth\UserDirectory;
|
||||
use App\Controllers\DashboardController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : injecte session test + FakeDatabase dans la garde,
|
||||
* l'autorisation et l'annuaire, sans base reelle.
|
||||
*/
|
||||
final class TestDashboardController extends DashboardController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->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<string> */
|
||||
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' => '<script>alert(1)</script>',
|
||||
'last_name' => 'x',
|
||||
'role_label' => 'Admin <b>& co</b>',
|
||||
];
|
||||
$db->permissionCodes = ['user.read'];
|
||||
|
||||
$body = $this->controller($this->authedSession(), $db)->index()->body();
|
||||
|
||||
self::assertStringContainsString('<script>', $body);
|
||||
self::assertStringNotContainsString('<script>alert(1)</script>', $body);
|
||||
self::assertStringContainsString('& co', $body);
|
||||
self::assertStringNotContainsString('Admin <b>', $body);
|
||||
}
|
||||
}
|
||||
46
tests/Unit/Auth/UserDirectoryTest.php
Normal file
46
tests/Unit/Auth/UserDirectoryTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\UserDirectory;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Lecture des infos d'affichage (nom + libelle de role) pour l'entete admin.
|
||||
*/
|
||||
final class UserDirectoryTest extends TestCase
|
||||
{
|
||||
private FakeDatabase $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue