feat: Audit trail pour actions sensibles
Story 1.7 - Implémente un système complet d'audit trail pour tracer toutes les actions sensibles (authentification, modifications de données, exports) avec immuabilité garantie par PostgreSQL. Fonctionnalités principales: - Table audit_log append-only avec contraintes PostgreSQL (RULE) - AuditLogger centralisé avec injection automatique du contexte - Correlation ID pour traçabilité distribuée (HTTP + async) - Handlers pour événements d'authentification - Commande d'archivage des logs anciens - Pas de PII dans les logs (emails/IPs hashés) Infrastructure: - Middlewares Messenger pour propagation du Correlation ID - HTTP middleware pour génération/propagation du Correlation ID - Support multi-tenant avec TenantResolver
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* [P0] Functional tests for account activation endpoints.
|
||||
*
|
||||
* Verifies:
|
||||
* - Token info endpoint accessibility (public)
|
||||
* - Activate endpoint accessibility (public)
|
||||
* - Token validation
|
||||
* - Password requirements validation
|
||||
*/
|
||||
final class ActivationEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
#[Test]
|
||||
public function activationTokenInfoEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Requesting token info for an invalid token
|
||||
$response = $client->request('GET', '/api/activation-tokens/550e8400-e29b-41d4-a716-446655440000', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 404 (not 401) because endpoint is public
|
||||
// Invalid token returns 404 Not Found
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting to activate with invalid token
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [
|
||||
'tokenValue' => '550e8400-e29b-41d4-a716-446655440000',
|
||||
'password' => 'ValidPassword123!',
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 404 (token not found) not 401 (unauthorized)
|
||||
// The endpoint is accessible without JWT, it validates the token's existence
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Activation endpoint should be accessible without JWT');
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointValidatesPasswordRequirements(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting activation with weak password
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [
|
||||
'tokenValue' => '550e8400-e29b-41d4-a716-446655440000',
|
||||
'password' => 'weak', // Too short, no uppercase, no number, no special char
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 422 Unprocessable Entity for validation errors
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointRequiresTokenAndPassword(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting activation without required fields
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 422 for missing required fields
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* [P0] Functional tests for the refresh token endpoint.
|
||||
*
|
||||
* Verifies:
|
||||
* - Endpoint accessibility
|
||||
* - Missing token handling (401)
|
||||
* - Invalid token handling
|
||||
* - Cookie-based authentication
|
||||
*/
|
||||
final class RefreshTokenEndpointTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
#[Test]
|
||||
public function refreshEndpointReturns401WithoutCookie(): void
|
||||
{
|
||||
// GIVEN: No refresh_token cookie
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling refresh endpoint
|
||||
$response = $client->request('POST', '/api/token/refresh', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 401 Unauthorized
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointIsAccessibleWithoutToken(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling logout endpoint
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 200 OK (idempotent - no token to invalidate)
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointClearsCookies(): void
|
||||
{
|
||||
// GIVEN: A client
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling logout
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Cookie' => 'refresh_token=some-token-value',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Response sets expired cookies
|
||||
$setCookieHeaders = $response->getHeaders(false)['set-cookie'] ?? [];
|
||||
$this->assertNotEmpty($setCookieHeaders);
|
||||
|
||||
$hasClearedCookie = false;
|
||||
foreach ($setCookieHeaders as $cookie) {
|
||||
if (str_contains($cookie, 'refresh_token=') && str_contains($cookie, 'expires=')) {
|
||||
$hasClearedCookie = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertTrue($hasClearedCookie, 'Should set expired refresh_token cookie');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshEndpointWithInvalidTokenReturns401(): void
|
||||
{
|
||||
// GIVEN: An invalid/malformed token in cookie
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling refresh with invalid cookie
|
||||
$response = $client->request('POST', '/api/token/refresh', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Cookie' => 'refresh_token=invalid-token-format',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 401 Unauthorized
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user