Compare commits
No commits in common. "5b714e9a3a2d03006e7fb66635e1bf27294b3922" and "1b0b20c12d9e801d0534ca9a6d00a703f7438719" have entirely different histories.
5b714e9a3a
...
1b0b20c12d
9 changed files with 0 additions and 552 deletions
|
|
@ -99,13 +99,6 @@ Autres regles :
|
||||||
| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) |
|
| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) |
|
||||||
| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe |
|
| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe |
|
||||||
| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) |
|
| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) |
|
||||||
| GET | `/api/me` | session | JSON | identite + permissions du compte courant (RG-6/RG-T02/RG-T03) |
|
|
||||||
|
|
||||||
`/api/me` est le premier consommateur reel de `SessionGuard` (RG-6 idle/absolu + RG-T02
|
|
||||||
is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Reponse :
|
|
||||||
`{ "data": { "user_id", "role_id", "role_code", "permissions": [...] } }` ; `401 AUTH_REQUIRED`
|
|
||||||
si la session est absente, expiree ou le compte desactive. Les autorisations par operation
|
|
||||||
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
|
|
||||||
|
|
||||||
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)
|
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use App\Core\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verification d'autorisation par PERMISSION (RG-T03), pas par nom de role : on
|
|
||||||
* teste qu'un role detient un code de permission via role_permission. Les droits
|
|
||||||
* sont recharges depuis la base a CHAQUE appel (mlt.md 10.4 RG-3) ; la session ne
|
|
||||||
* porte que role_id, donc un changement RBAC prend effet a la verification suivante
|
|
||||||
* sans invalider les sessions.
|
|
||||||
*
|
|
||||||
* Un role inactif (role.is_active = 0) ne confere aucune permission.
|
|
||||||
*/
|
|
||||||
final class Authorizer
|
|
||||||
{
|
|
||||||
public function __construct(private readonly DatabaseInterface $db)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function can(int $roleId, string $permissionCode): bool
|
|
||||||
{
|
|
||||||
$row = $this->db->fetch(
|
|
||||||
'SELECT 1 AS granted FROM role_permission rp '
|
|
||||||
. 'JOIN permission p ON p.id = rp.permission_id '
|
|
||||||
. 'JOIN role r ON r.id = rp.role_id '
|
|
||||||
. 'WHERE rp.role_id = :role AND p.code = :code AND r.is_active = 1 LIMIT 1',
|
|
||||||
['role' => $roleId, 'code' => $permissionCode],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $row !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste des codes de permission du role (pour /api/me et l'affichage UI).
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function permissionsFor(int $roleId): array
|
|
||||||
{
|
|
||||||
$rows = $this->db->fetchAll(
|
|
||||||
'SELECT p.code FROM role_permission rp '
|
|
||||||
. 'JOIN permission p ON p.id = rp.permission_id '
|
|
||||||
. 'JOIN role r ON r.id = rp.role_id '
|
|
||||||
. 'WHERE rp.role_id = :role AND r.is_active = 1 ORDER BY p.code',
|
|
||||||
['role' => $roleId],
|
|
||||||
);
|
|
||||||
|
|
||||||
$codes = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$code = $row['code'] ?? null;
|
|
||||||
if (is_string($code)) {
|
|
||||||
$codes[] = $code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $codes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code du role (ex. 'admin', 'counter'). Lecture de metadonnee de role,
|
|
||||||
* regroupee ici avec l'acces a role_permission pour un seul seam de donnees.
|
|
||||||
*/
|
|
||||||
public function roleCode(int $roleId): ?string
|
|
||||||
{
|
|
||||||
// Filtre is_active comme can()/permissionsFor() : un role desactive ne
|
|
||||||
// doit exposer ni droits ni libelle exploitable (coherence de l'invariant).
|
|
||||||
$row = $this->db->fetch('SELECT r.code FROM role r WHERE r.id = :id AND r.is_active = 1', ['id' => $roleId]);
|
|
||||||
|
|
||||||
return is_string($row['code'] ?? null) ? $row['code'] : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Auth\Authorizer;
|
|
||||||
use App\Auth\SessionGuard;
|
|
||||||
use App\Auth\SessionManager;
|
|
||||||
use App\Core\Controller;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base des controleurs proteges : fournit la session, la garde de session
|
|
||||||
* (RG-6 + RG-T02) et le service d'autorisation (RG-T03), cables depuis la Config
|
|
||||||
* et la Database injectees. Vit dans App\Controllers (au-dessus de Core et Auth)
|
|
||||||
* pour ne pas inverser la dependance du Core vers Auth.
|
|
||||||
*
|
|
||||||
* Les hooks sont proteges et surchargeables en test pour injecter des doubles.
|
|
||||||
*/
|
|
||||||
abstract class AuthenticatedController extends Controller
|
|
||||||
{
|
|
||||||
protected function sessionManager(): SessionManager
|
|
||||||
{
|
|
||||||
return new SessionManager($this->config);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function sessionGuard(): SessionGuard
|
|
||||||
{
|
|
||||||
return new SessionGuard($this->sessionManager(), $this->database, $this->config);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function authorizer(): Authorizer
|
|
||||||
{
|
|
||||||
return new Authorizer($this->database);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Core\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/me : identite et permissions de l'utilisateur de la session courante.
|
|
||||||
*
|
|
||||||
* Premier consommateur reel de SessionGuard (RG-6 + RG-T02) et d'Authorizer
|
|
||||||
* (RG-T03) : la garde rejette toute session absente/expiree/inactive (401), sinon
|
|
||||||
* on renvoie le role et la liste des permissions rechargee depuis la base.
|
|
||||||
*
|
|
||||||
* NB P2 : les pages admin sont encore des fichiers statiques servis par Apache
|
|
||||||
* (hors PHP), donc non couvertes par cette garde. Leur protection arrive en P3
|
|
||||||
* quand elles deviennent des vues rendues serveur passant par ce socle.
|
|
||||||
*
|
|
||||||
* Non `final` a dessein : les tests sous-classent pour injecter des doubles
|
|
||||||
* (session en mode test + FakeDatabase) via les hooks proteges.
|
|
||||||
*/
|
|
||||||
class MeController extends AuthenticatedController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function show(array $params = []): Response
|
|
||||||
{
|
|
||||||
$guard = $this->sessionGuard()->check();
|
|
||||||
|
|
||||||
if (!$guard->authenticated || $guard->userId === null || $guard->roleId === null) {
|
|
||||||
return $this->json(
|
|
||||||
['data' => null, 'error' => ['code' => 'AUTH_REQUIRED', 'message' => 'Authentification requise']],
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authorizer = $this->authorizer();
|
|
||||||
|
|
||||||
return $this->json([
|
|
||||||
'data' => [
|
|
||||||
'user_id' => $guard->userId,
|
|
||||||
'role_id' => $guard->roleId,
|
|
||||||
'role_code' => $authorizer->roleCode($guard->roleId),
|
|
||||||
'permissions' => $authorizer->permissionsFor($guard->roleId),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@ use App\Auth\SessionManager;
|
||||||
use App\Controllers\AuthController;
|
use App\Controllers\AuthController;
|
||||||
use App\Controllers\HealthController;
|
use App\Controllers\HealthController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
use App\Controllers\MeController;
|
|
||||||
use App\Controllers\PasswordResetController;
|
use App\Controllers\PasswordResetController;
|
||||||
use App\Core\Autoloader;
|
use App\Core\Autoloader;
|
||||||
use App\Core\Config;
|
use App\Core\Config;
|
||||||
|
|
@ -58,9 +57,6 @@ try {
|
||||||
$router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']);
|
$router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']);
|
||||||
$router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']);
|
$router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']);
|
||||||
|
|
||||||
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
|
|
||||||
$router->add('GET', '/api/me', [MeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch(Request::fromGlobals());
|
$response = $router->dispatch(Request::fromGlobals());
|
||||||
$response->send();
|
$response->send();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Integration;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Throwable;
|
|
||||||
use App\Auth\Authorizer;
|
|
||||||
use App\Core\Config;
|
|
||||||
use App\Core\Database;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test d'integration de l'autorisation contre une vraie MariaDB (schema migre +
|
|
||||||
* seede). Garde anti-regression du predicat de securite `AND r.is_active = 1` et
|
|
||||||
* du filtrage par code de permission, que le double unitaire ne peut pas prouver.
|
|
||||||
*
|
|
||||||
* Auto-skip : ne s'execute que si WAKDO_DB_TESTS=1 ET base joignable.
|
|
||||||
* Isolation : cree un role jetable (code unique) + une ligne role_permission,
|
|
||||||
* supprimes en tearDown.
|
|
||||||
*/
|
|
||||||
final class AuthorizerDbTest extends TestCase
|
|
||||||
{
|
|
||||||
private Database $db;
|
|
||||||
private int $roleId = 0;
|
|
||||||
private string $roleCode = '';
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
|
||||||
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db = new Database(new Config());
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->db->fetch('SELECT 1');
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role jetable cree DESACTIVE, portant la permission product.read.
|
|
||||||
$this->roleCode = 'it-rbac-' . bin2hex(random_bytes(4));
|
|
||||||
$this->db->execute(
|
|
||||||
'INSERT INTO role (code, label, is_active) VALUES (:code, :label, 0)',
|
|
||||||
['code' => $this->roleCode, 'label' => 'IT RBAC'],
|
|
||||||
);
|
|
||||||
$this->roleId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
|
||||||
$this->db->execute(
|
|
||||||
'INSERT INTO role_permission (role_id, permission_id) '
|
|
||||||
. 'SELECT :rid, id FROM permission WHERE code = :pc',
|
|
||||||
['rid' => $this->roleId, 'pc' => 'product.read'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
if ($this->roleId === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $this->roleId]);
|
|
||||||
$this->db->execute('DELETE FROM role WHERE id = :id', ['id' => $this->roleId]);
|
|
||||||
$this->roleId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInactiveRoleGrantsNothingThenActiveGrants(): void
|
|
||||||
{
|
|
||||||
$authz = new Authorizer($this->db);
|
|
||||||
|
|
||||||
// is_active = 0 : aucun droit ni libelle, malgre la ligne role_permission.
|
|
||||||
self::assertFalse($authz->can($this->roleId, 'product.read'));
|
|
||||||
self::assertSame([], $authz->permissionsFor($this->roleId));
|
|
||||||
self::assertNull($authz->roleCode($this->roleId));
|
|
||||||
|
|
||||||
// On active le role : le meme grant devient effectif -> c'est bien le
|
|
||||||
// predicat is_active qui gate (et non l'absence de role_permission).
|
|
||||||
$this->db->execute('UPDATE role SET is_active = 1 WHERE id = :id', ['id' => $this->roleId]);
|
|
||||||
|
|
||||||
self::assertTrue($authz->can($this->roleId, 'product.read'));
|
|
||||||
self::assertSame(['product.read'], $authz->permissionsFor($this->roleId));
|
|
||||||
self::assertSame($this->roleCode, $authz->roleCode($this->roleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSeededAdminRoleFiltersByPermissionCode(): void
|
|
||||||
{
|
|
||||||
$authz = new Authorizer($this->db);
|
|
||||||
$adminId = (int) ($this->db->fetch("SELECT id FROM role WHERE code = 'admin'")['id'] ?? 0);
|
|
||||||
self::assertGreaterThan(0, $adminId, 'role admin seede attendu');
|
|
||||||
|
|
||||||
// RG-T03 : filtrage par code (admin detient product.create, pas une permission inventee).
|
|
||||||
self::assertTrue($authz->can($adminId, 'product.create'));
|
|
||||||
self::assertFalse($authz->can($adminId, 'totally.fake.permission'));
|
|
||||||
self::assertContains('role.manage', $authz->permissionsFor($adminId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -55,34 +55,6 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
*/
|
*/
|
||||||
public ?array $guardUserRow = null;
|
public ?array $guardUserRow = null;
|
||||||
|
|
||||||
/** Resultat de Authorizer::can() (true = permission accordee). */
|
|
||||||
public bool $canResult = false;
|
|
||||||
|
|
||||||
/** Etat role.is_active modelise pour can()/permissionsFor() ; false => rien accorde. */
|
|
||||||
public bool $roleActive = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trace des lectures (fetch/fetchAll) pour asserter les parametres lies
|
|
||||||
* (ex. liaison par code de permission, RG-T03), pendant que $writes trace les ecritures.
|
|
||||||
*
|
|
||||||
* @var list<array{sql: string, params: array<string|int, mixed>}>
|
|
||||||
*/
|
|
||||||
public array $reads = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Codes de permission renvoyes par Authorizer::permissionsFor().
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
public array $permissionCodes = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ligne role renvoyee pour la lecture du code de role (/api/me) ; null = absent.
|
|
||||||
*
|
|
||||||
* @var array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public ?array $roleRow = null;
|
|
||||||
|
|
||||||
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
|
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
|
||||||
public ?RuntimeException $failOnExecute = null;
|
public ?RuntimeException $failOnExecute = null;
|
||||||
|
|
||||||
|
|
@ -94,8 +66,6 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
|
|
||||||
public function fetch(string $sql, array $params = []): ?array
|
public function fetch(string $sql, array $params = []): ?array
|
||||||
{
|
{
|
||||||
$this->reads[] = ['sql' => $sql, 'params' => $params];
|
|
||||||
|
|
||||||
if (str_contains($sql, 'FROM user u JOIN role')) {
|
if (str_contains($sql, 'FROM user u JOIN role')) {
|
||||||
return $this->userRow;
|
return $this->userRow;
|
||||||
}
|
}
|
||||||
|
|
@ -112,14 +82,6 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
return $this->guardUserRow;
|
return $this->guardUserRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) {
|
|
||||||
return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($sql, 'FROM role r WHERE r.id')) {
|
|
||||||
return $this->roleRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
|
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
|
||||||
return ['lockout_until' => $this->ipLockoutUntil];
|
return ['lockout_until' => $this->ipLockoutUntil];
|
||||||
}
|
}
|
||||||
|
|
@ -133,16 +95,6 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
|
|
||||||
public function fetchAll(string $sql, array $params = []): array
|
public function fetchAll(string $sql, array $params = []): array
|
||||||
{
|
{
|
||||||
$this->reads[] = ['sql' => $sql, 'params' => $params];
|
|
||||||
|
|
||||||
if (str_contains($sql, 'SELECT p.code FROM role_permission')) {
|
|
||||||
if (!$this->roleActive) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map(static fn (string $code): array => ['code' => $code], $this->permissionCodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit\Auth;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Auth\Authorizer;
|
|
||||||
use App\Tests\Support\FakeDatabase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verification par permission (RG-T03) : can() / permissionsFor() / roleCode()
|
|
||||||
* testes avec un FakeDatabase, sans base reelle.
|
|
||||||
*/
|
|
||||||
final class AuthorizerTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeDatabase $db;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->db = new FakeDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizer(): Authorizer
|
|
||||||
{
|
|
||||||
return new Authorizer($this->db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCanReturnsTrueWhenPermissionGranted(): void
|
|
||||||
{
|
|
||||||
$this->db->canResult = true;
|
|
||||||
|
|
||||||
self::assertTrue($this->authorizer()->can(1, 'product.create'));
|
|
||||||
// RG-T03 : la verification lie le CODE de permission + le role_id (jamais
|
|
||||||
// un nom de role). On asserte les parametres reellement lies a la requete.
|
|
||||||
self::assertSame(['role' => 1, 'code' => 'product.create'], $this->lastRead()['params']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCanReturnsFalseWhenNotGranted(): void
|
|
||||||
{
|
|
||||||
$this->db->canResult = false;
|
|
||||||
|
|
||||||
self::assertFalse($this->authorizer()->can(3, 'order.cancel'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPermissionsForReturnsCodes(): void
|
|
||||||
{
|
|
||||||
$this->db->permissionCodes = ['order.read', 'product.read', 'stock.read'];
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
['order.read', 'product.read', 'stock.read'],
|
|
||||||
$this->authorizer()->permissionsFor(4),
|
|
||||||
);
|
|
||||||
self::assertSame(['role' => 4], $this->lastRead()['params']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPermissionsForReturnsEmptyWhenNone(): void
|
|
||||||
{
|
|
||||||
$this->db->permissionCodes = [];
|
|
||||||
|
|
||||||
self::assertSame([], $this->authorizer()->permissionsFor(9));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRoleCodeReturnsCodeOrNull(): void
|
|
||||||
{
|
|
||||||
$this->db->roleRow = ['code' => 'admin'];
|
|
||||||
self::assertSame('admin', $this->authorizer()->roleCode(1));
|
|
||||||
|
|
||||||
$this->db->roleRow = null;
|
|
||||||
self::assertNull($this->authorizer()->roleCode(999));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCanDeniesWhenRoleInactive(): void
|
|
||||||
{
|
|
||||||
// Le role detient la permission (canResult) mais il est desactive : refus.
|
|
||||||
$this->db->canResult = true;
|
|
||||||
$this->db->roleActive = false;
|
|
||||||
|
|
||||||
self::assertFalse($this->authorizer()->can(1, 'product.create'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPermissionsForEmptyWhenRoleInactive(): void
|
|
||||||
{
|
|
||||||
$this->db->permissionCodes = ['order.read', 'product.read'];
|
|
||||||
$this->db->roleActive = false;
|
|
||||||
|
|
||||||
self::assertSame([], $this->authorizer()->permissionsFor(4));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{sql: string, params: array<string|int, mixed>}
|
|
||||||
*/
|
|
||||||
private function lastRead(): array
|
|
||||||
{
|
|
||||||
$reads = $this->db->reads;
|
|
||||||
self::assertNotEmpty($reads, 'aucune lecture enregistree');
|
|
||||||
|
|
||||||
return $reads[array_key_last($reads)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit\Auth;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Auth\Authorizer;
|
|
||||||
use App\Auth\SessionGuard;
|
|
||||||
use App\Auth\SessionManager;
|
|
||||||
use App\Controllers\MeController;
|
|
||||||
use App\Core\Config;
|
|
||||||
use App\Core\Database;
|
|
||||||
use App\Core\Request;
|
|
||||||
use App\Tests\Support\FakeDatabase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sous-classe de test : injecte une session en mode test et un FakeDatabase dans
|
|
||||||
* la garde et l'autorisation, sans base reelle.
|
|
||||||
*/
|
|
||||||
final class TestMeController extends MeController
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class MeControllerTest 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): TestMeController
|
|
||||||
{
|
|
||||||
$request = new Request('GET', '/api/me', [], [], '', '203.0.113.5');
|
|
||||||
|
|
||||||
return new TestMeController($request, new Config(), new Database(new Config()), $session, $db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNoSessionReturns401(): void
|
|
||||||
{
|
|
||||||
$response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->show();
|
|
||||||
|
|
||||||
self::assertSame(401, $response->status());
|
|
||||||
|
|
||||||
$body = json_decode($response->body(), true);
|
|
||||||
self::assertIsArray($body);
|
|
||||||
self::assertSame('AUTH_REQUIRED', $body['error']['code'] ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticatedReturnsIdentityAndPermissions(): void
|
|
||||||
{
|
|
||||||
$session = new SessionManager(new Config(), true);
|
|
||||||
// Horodatages relatifs a l'instant reel : MeController appelle check() sans
|
|
||||||
// injecter de temps (now = time()).
|
|
||||||
$now = time();
|
|
||||||
$session->set('user_id', 7);
|
|
||||||
$session->set('role_id', 3);
|
|
||||||
$session->set('logged_in_at', $now - 100);
|
|
||||||
$session->set('last_activity', $now - 50);
|
|
||||||
|
|
||||||
$db = new FakeDatabase();
|
|
||||||
$db->guardUserRow = ['is_active' => 1];
|
|
||||||
$db->roleRow = ['code' => 'manager'];
|
|
||||||
$db->permissionCodes = ['product.read', 'stats.read'];
|
|
||||||
|
|
||||||
$response = $this->controller($session, $db)->show();
|
|
||||||
|
|
||||||
self::assertSame(200, $response->status());
|
|
||||||
|
|
||||||
$body = json_decode($response->body(), true);
|
|
||||||
self::assertIsArray($body);
|
|
||||||
self::assertSame(7, $body['data']['user_id'] ?? null);
|
|
||||||
self::assertSame(3, $body['data']['role_id'] ?? null);
|
|
||||||
self::assertSame('manager', $body['data']['role_code'] ?? null);
|
|
||||||
self::assertSame(['product.read', 'stats.read'], $body['data']['permissions'] ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInactiveUserSessionReturns401(): void
|
|
||||||
{
|
|
||||||
$session = new SessionManager(new Config(), true);
|
|
||||||
$now = time();
|
|
||||||
$session->set('user_id', 7);
|
|
||||||
$session->set('role_id', 3);
|
|
||||||
$session->set('logged_in_at', $now - 100);
|
|
||||||
$session->set('last_activity', $now - 50);
|
|
||||||
|
|
||||||
$db = new FakeDatabase();
|
|
||||||
$db->guardUserRow = ['is_active' => 0];
|
|
||||||
|
|
||||||
$response = $this->controller($session, $db)->show();
|
|
||||||
|
|
||||||
self::assertSame(401, $response->status());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue