feat: add security layer, domain repositories and infrastructure services
ApiKeyAuthenticator, PermissionVoter and UserProvider implement Symfony Security for the API (Bearer token) and admin (session) flows. Domain repository interfaces added for ApiKey, User, AIPipelineJob and Invoice; Doctrine implementations provided. Also adds DatabaseLogHandler for structured DB logging, SerpApiWebSearch, and the LogEntry domain entity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46cff4553f
commit
487c7f8da1
16 changed files with 655 additions and 0 deletions
22
src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php
Normal file
22
src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Auth\Repository;
|
||||
|
||||
use App\Domain\Auth\ApiKey;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface ApiKeyRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?ApiKey;
|
||||
|
||||
public function findByPrefix(string $prefix): ?ApiKey;
|
||||
|
||||
/** @return list<ApiKey> */
|
||||
public function findByUser(Uuid $userId): array;
|
||||
|
||||
public function save(ApiKey $key): void;
|
||||
|
||||
public function remove(ApiKey $key): void;
|
||||
}
|
||||
17
src/Domain/Auth/Repository/UserRepositoryInterface.php
Normal file
17
src/Domain/Auth/Repository/UserRepositoryInterface.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Auth\Repository;
|
||||
|
||||
use App\Domain\Auth\User;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface UserRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?User;
|
||||
|
||||
public function findByEmail(string $email): ?User;
|
||||
|
||||
public function save(User $user): void;
|
||||
}
|
||||
102
src/Domain/Log/LogEntry.php
Normal file
102
src/Domain/Log/LogEntry.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Log;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'log_entries', schema: 'logs')]
|
||||
class LogEntry
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50)]
|
||||
private string $channel;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $level;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50)]
|
||||
private string $levelName;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $message;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $context;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $extra;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $loggedAt;
|
||||
|
||||
/** @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $extra */
|
||||
public function __construct(
|
||||
string $channel,
|
||||
int $level,
|
||||
string $levelName,
|
||||
string $message,
|
||||
array $context,
|
||||
array $extra,
|
||||
\DateTimeImmutable $loggedAt,
|
||||
) {
|
||||
$this->channel = $channel;
|
||||
$this->level = $level;
|
||||
$this->levelName = $levelName;
|
||||
$this->message = $message;
|
||||
$this->context = $context;
|
||||
$this->extra = $extra;
|
||||
$this->loggedAt = $loggedAt;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getChannel(): string
|
||||
{
|
||||
return $this->channel;
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return $this->level;
|
||||
}
|
||||
|
||||
public function getLevelName(): string
|
||||
{
|
||||
return $this->levelName;
|
||||
}
|
||||
|
||||
public function getMessage(): string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getExtra(): array
|
||||
{
|
||||
return $this->extra;
|
||||
}
|
||||
|
||||
public function getLoggedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->loggedAt;
|
||||
}
|
||||
}
|
||||
17
src/Domain/Order/Repository/InvoiceRepositoryInterface.php
Normal file
17
src/Domain/Order/Repository/InvoiceRepositoryInterface.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Order\Repository;
|
||||
|
||||
use App\Domain\Order\Invoice;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface InvoiceRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?Invoice;
|
||||
|
||||
public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice;
|
||||
|
||||
public function save(Invoice $invoice): void;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Pipeline\Repository;
|
||||
|
||||
use App\Domain\Pipeline\AIPipelineJob;
|
||||
use App\Domain\Pipeline\AIPipelineJobStatus;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
interface AIPipelineJobRepositoryInterface
|
||||
{
|
||||
public function findById(Uuid $id): ?AIPipelineJob;
|
||||
|
||||
/** @return list<AIPipelineJob> */
|
||||
public function findByStatus(AIPipelineJobStatus $status): array;
|
||||
|
||||
public function save(AIPipelineJob $job): void;
|
||||
}
|
||||
38
src/Infrastructure/Logging/DatabaseLogHandler.php
Normal file
38
src/Infrastructure/Logging/DatabaseLogHandler.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Logging;
|
||||
|
||||
use App\Domain\Log\LogEntry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Monolog\Handler\AbstractProcessingHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
final class DatabaseLogHandler extends AbstractProcessingHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
Level $level = Level::Debug,
|
||||
bool $bubble = true,
|
||||
) {
|
||||
parent::__construct($level, $bubble);
|
||||
}
|
||||
|
||||
protected function write(LogRecord $record): void
|
||||
{
|
||||
$entry = new LogEntry(
|
||||
$record->channel,
|
||||
$record->level->value,
|
||||
$record->level->getName(),
|
||||
$record->message,
|
||||
$record->context,
|
||||
$record->extra,
|
||||
\DateTimeImmutable::createFromInterface($record->datetime),
|
||||
);
|
||||
|
||||
$this->em->persist($entry);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Pipeline\AIPipelineJob;
|
||||
use App\Domain\Pipeline\AIPipelineJobStatus;
|
||||
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineAIPipelineJobRepository implements AIPipelineJobRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?AIPipelineJob
|
||||
{
|
||||
/** @var AIPipelineJob|null */
|
||||
return $this->em->find(AIPipelineJob::class, $id);
|
||||
}
|
||||
|
||||
public function findByStatus(AIPipelineJobStatus $status): array
|
||||
{
|
||||
/** @var list<AIPipelineJob> */
|
||||
return $this->em->getRepository(AIPipelineJob::class)->findBy(['status' => $status]);
|
||||
}
|
||||
|
||||
public function save(AIPipelineJob $job): void
|
||||
{
|
||||
$this->em->persist($job);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Auth\ApiKey;
|
||||
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineApiKeyRepository implements ApiKeyRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?ApiKey
|
||||
{
|
||||
/** @var ApiKey|null */
|
||||
return $this->em->find(ApiKey::class, $id);
|
||||
}
|
||||
|
||||
public function findByPrefix(string $prefix): ?ApiKey
|
||||
{
|
||||
/** @var ApiKey|null */
|
||||
return $this->em->getRepository(ApiKey::class)
|
||||
->createQueryBuilder('k')
|
||||
->where('k.keyPrefix = :prefix')
|
||||
->setParameter('prefix', $prefix)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findByUser(Uuid $userId): array
|
||||
{
|
||||
/** @var list<ApiKey> */
|
||||
return $this->em->getRepository(ApiKey::class)
|
||||
->createQueryBuilder('k')
|
||||
->join('k.user', 'u')
|
||||
->where('u.id = :userId')
|
||||
->setParameter('userId', $userId)
|
||||
->orderBy('k.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ApiKey $key): void
|
||||
{
|
||||
$this->em->persist($key);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function remove(ApiKey $key): void
|
||||
{
|
||||
$this->em->remove($key);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Order\Invoice;
|
||||
use App\Domain\Order\Repository\InvoiceRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineInvoiceRepository implements InvoiceRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?Invoice
|
||||
{
|
||||
return $this->em->find(Invoice::class, $id);
|
||||
}
|
||||
|
||||
public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice
|
||||
{
|
||||
return $this->em->getRepository(Invoice::class)->findOneBy(['frappeInvoiceId' => $frappeInvoiceId]);
|
||||
}
|
||||
|
||||
public function save(Invoice $invoice): void
|
||||
{
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Persistence\Repository;
|
||||
|
||||
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||||
use App\Domain\Auth\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class DoctrineUserRepository implements UserRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(Uuid $id): ?User
|
||||
{
|
||||
/** @var User|null */
|
||||
return $this->em->find(User::class, $id);
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): ?User
|
||||
{
|
||||
/** @var User|null */
|
||||
return $this->em->getRepository(User::class)
|
||||
->createQueryBuilder('u')
|
||||
->where('u.email = :email')
|
||||
->setParameter('email', $email)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
48
src/Infrastructure/Search/SerpApiWebSearch.php
Normal file
48
src/Infrastructure/Search/SerpApiWebSearch.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Search;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
final class SerpApiWebSearch implements WebSearchInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly string $serpApiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
public function search(string $query): string
|
||||
{
|
||||
$response = $this->httpClient->request('GET', 'https://serpapi.com/search', [
|
||||
'query' => [
|
||||
'q' => $query,
|
||||
'api_key' => $this->serpApiKey,
|
||||
'num' => 5,
|
||||
'hl' => 'de',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]);
|
||||
|
||||
/** @var array{organic_results?: list<array{title?: string, snippet?: string}>} $data */
|
||||
$data = $response->toArray();
|
||||
|
||||
$results = $data['organic_results'] ?? [];
|
||||
if ([] === $results) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$texts = [];
|
||||
foreach ($results as $result) {
|
||||
$title = $result['title'] ?? '';
|
||||
$snippet = $result['snippet'] ?? '';
|
||||
if ('' !== $title || '' !== $snippet) {
|
||||
$texts[] = $title."\n".$snippet;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", $texts);
|
||||
}
|
||||
}
|
||||
10
src/Infrastructure/Search/WebSearchInterface.php
Normal file
10
src/Infrastructure/Search/WebSearchInterface.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Search;
|
||||
|
||||
interface WebSearchInterface
|
||||
{
|
||||
public function search(string $query): string;
|
||||
}
|
||||
64
src/Infrastructure/Security/ApiKeyAuthenticator.php
Normal file
64
src/Infrastructure/Security/ApiKeyAuthenticator.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Security;
|
||||
|
||||
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
final class ApiKeyAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(private readonly ApiKeyRepositoryInterface $apiKeyRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request->headers->has('X-Api-Key');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$rawKey = $request->headers->get('X-Api-Key', '');
|
||||
if ('' === $rawKey) {
|
||||
throw new CustomUserMessageAuthenticationException('API key is missing.');
|
||||
}
|
||||
|
||||
$prefix = substr($rawKey, 0, 8);
|
||||
$apiKey = $this->apiKeyRepository->findByPrefix($prefix);
|
||||
|
||||
if (null === $apiKey || !$apiKey->isActive() || $apiKey->isExpired()) {
|
||||
throw new CustomUserMessageAuthenticationException('Invalid or expired API key.');
|
||||
}
|
||||
|
||||
if (!password_verify($rawKey, $apiKey->getKeyHash())) {
|
||||
throw new CustomUserMessageAuthenticationException('Invalid API key.');
|
||||
}
|
||||
|
||||
$apiKey->markUsed();
|
||||
|
||||
$user = $apiKey->getUser();
|
||||
|
||||
return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier()));
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
41
src/Infrastructure/Security/PermissionVoter.php
Normal file
41
src/Infrastructure/Security/PermissionVoter.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Security;
|
||||
|
||||
use App\Domain\Auth\ApiKey;
|
||||
use App\Domain\Auth\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/** @extends Voter<string, null> */
|
||||
final class PermissionVoter extends Voter
|
||||
{
|
||||
public const PREFIX = 'PERM_';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return str_starts_with($attribute, self::PREFIX);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$permission = substr($attribute, \strlen(self::PREFIX));
|
||||
|
||||
$user = $token->getUser();
|
||||
|
||||
if ($user instanceof User) {
|
||||
return $user->hasPermission($permission);
|
||||
}
|
||||
|
||||
// Support API key-based authentication via token attributes
|
||||
$apiKey = $token->getAttribute('api_key');
|
||||
if ($apiKey instanceof ApiKey) {
|
||||
return $apiKey->hasPermission($permission);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
49
src/Infrastructure/Security/UserProvider.php
Normal file
49
src/Infrastructure/Security/UserProvider.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Security;
|
||||
|
||||
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||||
use App\Domain\Auth\User;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/** @implements UserProviderInterface<User> */
|
||||
final class UserProvider implements UserProviderInterface
|
||||
{
|
||||
public function __construct(private readonly UserRepositoryInterface $userRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): UserInterface
|
||||
{
|
||||
$user = $this->userRepository->findByEmail($identifier);
|
||||
if (null === $user) {
|
||||
throw new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user): UserInterface
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
throw new UnsupportedUserException(\sprintf('Invalid user class "%s".', $user::class));
|
||||
}
|
||||
|
||||
$refreshed = $this->userRepository->findById($user->getId());
|
||||
if (null === $refreshed) {
|
||||
throw new UserNotFoundException('User not found.');
|
||||
}
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function supportsClass(string $class): bool
|
||||
{
|
||||
return User::class === $class || is_subclass_of($class, User::class);
|
||||
}
|
||||
}
|
||||
60
tests/Unit/Infrastructure/Security/PermissionVoterTest.php
Normal file
60
tests/Unit/Infrastructure/Security/PermissionVoterTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Infrastructure\Security;
|
||||
|
||||
use App\Domain\Auth\User;
|
||||
use App\Infrastructure\Security\PermissionVoter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
|
||||
final class PermissionVoterTest extends TestCase
|
||||
{
|
||||
private PermissionVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new PermissionVoter();
|
||||
}
|
||||
|
||||
public function testGrantsWhenUserHasPermission(): void
|
||||
{
|
||||
$user = new User('admin@test.com', 'hash');
|
||||
$user->grantPermission('articles.publish');
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_GRANTED, $this->voter->vote($token, null, ['PERM_articles.publish']));
|
||||
}
|
||||
|
||||
public function testDeniesWhenUserLacksPermission(): void
|
||||
{
|
||||
$user = new User('user@test.com', 'hash');
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token, null, ['PERM_articles.publish']));
|
||||
}
|
||||
|
||||
public function testAbstainsForNonPrefixedAttribute(): void
|
||||
{
|
||||
$user = new User('user@test.com', 'hash');
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote($token, null, ['ROLE_ADMIN']));
|
||||
}
|
||||
|
||||
public function testDeniesAfterPermissionRevoked(): void
|
||||
{
|
||||
$user = new User('user@test.com', 'hash');
|
||||
$user->grantPermission('articles.delete');
|
||||
$user->revokePermission('articles.delete');
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token, null, ['PERM_articles.delete']));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue