new API endpoint to save invoice meta-fields (#5916)

This commit is contained in:
Kevin Papst
2026-04-25 18:14:10 +02:00
committed by GitHub
parent 087350ab72
commit 3eebb02ad3
39 changed files with 1427 additions and 659 deletions

View File

@@ -136,7 +136,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
]);
}
protected function assertEntityNotFoundForPatch(string $role, string $url, array $data): void
protected function assertEntityNotFoundForPatch(HttpKernelBrowser|string $role, string $url, array $data): void
{
$this->assertExceptionForPatchAction($role, $url, $data, [
'code' => Response::HTTP_NOT_FOUND,
@@ -152,17 +152,17 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
]);
}
protected function assertExceptionForDeleteAction(string $role, string $url, array $data, array $expectedErrors): void
protected function assertExceptionForDeleteAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void
{
$this->assertExceptionForRole($role, $url, 'DELETE', $data, $expectedErrors);
}
protected function assertExceptionForPatchAction(string $role, string $url, array $data, array $expectedErrors): void
protected function assertExceptionForPatchAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void
{
$this->assertExceptionForRole($role, $url, 'PATCH', $data, $expectedErrors);
}
protected function assertExceptionForPostAction(string $role, string $url, array $data, array $expectedErrors): void
protected function assertExceptionForPostAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void
{
$this->assertExceptionForRole($role, $url, 'POST', $data, $expectedErrors);
}
@@ -180,9 +180,9 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
self::assertEquals($expectedErrors, json_decode($response->getContent(), true));
}
protected function assertExceptionForRole(string $role, string $url, string $method, array $data, array $expectedErrors): void
protected function assertExceptionForRole(HttpKernelBrowser|string $role, string $url, string $method, array $data, array $expectedErrors): void
{
$client = $this->getClientForAuthenticatedUser($role);
$client = ($role instanceof HttpKernelBrowser) ? $role : $this->getClientForAuthenticatedUser($role);
$this->assertExceptionForMethod($client, $url, $method, $data, $expectedErrors);
}
@@ -321,7 +321,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'total' => 'float',
'vat' => 'float',
'overdue' => 'bool',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
'metaFields' => ['result' => 'array', 'type' => 'InvoiceMeta'],
];
case 'PageActionItem':
@@ -350,6 +350,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'value' => '@string',
];
case 'InvoiceMeta':
case 'CustomerMeta':
case 'ProjectMeta':
case 'ActivityMeta':
@@ -630,7 +631,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45
'comment' => '@string',
];
@@ -644,7 +645,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45
'comment' => '@string',
];
@@ -659,7 +660,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'],
'comment' => '@string',
'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'],
@@ -676,7 +677,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'],
'comment' => '@string',
'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'],

View File

@@ -76,6 +76,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
'/api/invoices/{id}/custom-fields',
'/api/projects',
'/api/projects/{id}',
'/api/projects/{id}/meta',

View File

@@ -15,13 +15,17 @@ use App\Entity\Team;
use App\Entity\User;
use App\Repository\TeamRepository;
use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Response;
#[Group('integration')]
class InvoiceControllerTest extends APIControllerBaseTestCase
{
/**
* @return Invoice[]
* @param int<1, 999> $amount
* @return non-empty-array<Invoice>
*/
protected function importInvoiceFixtures(int $amount, ?array $status = null): array
{
@@ -120,6 +124,8 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('Invoice', $result);
self::assertArrayHasKey('metaFields', $result);
self::assertCount(0, $result['metaFields']);
}
public function testNotFound(): void
@@ -181,4 +187,95 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
$this->request($client, '/api/invoices', 'GET', $query);
$this->assertApiResponseAccessDenied($client->getResponse());
}
// ------------------------------------- [META FIELDS] -------------------------------------
public function testUpdateInvoiceMetaFieldsThrowsNotFound(): void
{
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/invoices/42/custom-fields', []);
}
public function testUpdateInvoiceMetaFieldsThrowsExceptionOnWrongStructure(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$id = $this->importInvoiceFixtures(1)[0]->getId();
$this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', ['name' => 'X', 'value' => 'X'], [
'code' => Response::HTTP_BAD_REQUEST,
'message' => 'Bad Request'
]);
}
public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingName(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$id = $this->importInvoiceFixtures(1)[0]->getId();
$this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['value' => 'X']], [
'code' => Response::HTTP_BAD_REQUEST,
'message' => 'Bad Request'
]);
}
public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingValue(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$id = $this->importInvoiceFixtures(1)[0]->getId();
$this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['name' => 'X']], [
'code' => Response::HTTP_BAD_REQUEST,
'message' => 'Bad Request'
]);
}
public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingMetafield(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$id = $this->importInvoiceFixtures(1)[0]->getId();
$this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['name' => 'X', 'value' => 'Y']], [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
public function testUpdateInvoiceMetaFields(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$invoices = $this->importInvoiceFixtures(1);
$id = $invoices[0]->getId();
/** @var EventDispatcher $dispatcher */
$dispatcher = static::getContainer()->get('event_dispatcher');
$dispatcher->addSubscriber(new InvoiceTestMetaFieldSubscriberMock());
$data = [
[
'name' => 'metatestmock',
'value' => 'another,testing,bar'
],
[
'name' => 'foobar',
'value' => 13081978
],
];
$this->request($client, '/api/invoices/' . $id . '/custom-fields', 'PATCH', [], (string) json_encode($data));
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('Invoice', $result);
self::assertArrayHasKey('metaFields', $result);
// only visible should be returned
self::assertCount(1, $result['metaFields']);
self::assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]);
$em = $this->getEntityManager();
/** @var Invoice $invoice */
$invoice = $em->getRepository(Invoice::class)->find($id);
self::assertEquals('another,testing,bar', $invoice->getMetaField('metatestmock')?->getValue());
self::assertEquals(13081978, $invoice->getMetaField('foobar')?->getValue());
}
}

View File

@@ -13,7 +13,7 @@ use App\Command\InvoiceCreateCommand;
use App\DataFixtures\UserFixtures;
use App\Entity\Customer;
use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository;
@@ -67,7 +67,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
$container = self::getContainer();
$this->application->add(new InvoiceCreateCommand(
$container->get(ServiceInvoice::class), // @phpstan-ignore argument.type
$container->get(InvoiceService::class), // @phpstan-ignore argument.type
$container->get(CustomerRepository::class), // @phpstan-ignore argument.type
$container->get(ProjectRepository::class), // @phpstan-ignore argument.type
$container->get(InvoiceTemplateRepository::class), // @phpstan-ignore argument.type

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Activity>
*/
final class ActivityFixtures implements TestFixture
{

View File

@@ -15,6 +15,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Customer>
*/
final class CustomerFixtures implements TestFixture
{

View File

@@ -12,6 +12,9 @@ namespace App\Tests\DataFixtures;
use App\Entity\ExportTemplate;
use Doctrine\Persistence\ObjectManager;
/**
* @implements TestFixture<ExportTemplate>
*/
final class ExportTemplateFixtures implements TestFixture
{
/**

View File

@@ -15,6 +15,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Invoice>
*/
class InvoiceFixtures implements TestFixture
{

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<InvoiceTemplate>
*/
class InvoiceTemplateFixtures implements TestFixture
{

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Project>
*/
final class ProjectFixtures implements TestFixture
{

View File

@@ -14,6 +14,7 @@ use Doctrine\Persistence\ObjectManager;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Tag>
*/
final class TagFixtures implements TestFixture
{

View File

@@ -15,6 +15,7 @@ use Doctrine\Persistence\ObjectManager;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Team>
*/
final class TeamFixtures implements TestFixture
{

View File

@@ -13,11 +13,14 @@ use Doctrine\Persistence\ObjectManager;
/**
* Defines the sample data to load in during controller tests.
* @template TEntity
*/
interface TestFixture
{
/**
* Load data fixtures with the passed EntityManager and returns the created objects.
*
* @return non-empty-array<TEntity>
*/
public function load(ObjectManager $manager): array;
}

View File

@@ -22,6 +22,7 @@ use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
* @implements TestFixture<Timesheet>
*/
final class TimesheetFixtures implements TestFixture
{

View File

@@ -15,12 +15,12 @@ use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\Renderer\DocxRenderer;
use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice;
use App\Repository\TimesheetInvoiceItemRepository;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -34,8 +34,8 @@ class InvoiceServiceCompilerPassTest extends TestCase
{
$container = new ContainerBuilder();
$definition = new Definition(ServiceInvoice::class);
$container->setDefinition(ServiceInvoice::class, $definition);
$definition = new Definition(InvoiceService::class);
$container->setDefinition(InvoiceService::class, $definition);
$renderers = [DocxRenderer::class];
foreach ($renderers as $renderer) {
@@ -66,7 +66,7 @@ class InvoiceServiceCompilerPassTest extends TestCase
$sut = new InvoiceServiceCompilerPass();
$sut->process($container);
$definition = $container->findDefinition(ServiceInvoice::class);
$definition = $container->findDefinition(InvoiceService::class);
$methods = $definition->getMethodCalls();
self::assertCount(7, $methods);

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Invoice;
use App\Event\InvoiceUpdatePostEvent;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceUpdatePostEvent::class)]
class InvoiceUpdatePostEventTest extends TestCase
{
public function testDefaultValues(): void
{
$invoice = new Invoice();
$sut = new InvoiceUpdatePostEvent($invoice);
self::assertSame($invoice, $sut->getInvoice());
}
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Invoice;
use App\Event\InvoiceUpdatePreEvent;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceUpdatePreEvent::class)]
class InvoiceUpdatePreEventTest extends TestCase
{
public function testDefaultValues(): void
{
$invoice = new Invoice();
$sut = new InvoiceUpdatePreEvent($invoice);
self::assertSame($invoice, $sut->getInvoice());
}
}

View File

@@ -18,6 +18,7 @@ use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
@@ -29,12 +30,14 @@ use App\Tests\Mocks\InvoiceModelFactoryFactory;
use App\Utils\FileHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
#[CoversClass(ServiceInvoice::class)]
class ServiceInvoiceTest extends TestCase
#[CoversClass(InvoiceService::class)]
#[CoversClass(ServiceInvoice::class)] // @phpstan-ignore-line
class InvoiceServiceTest extends TestCase
{
private function getSut(array $paths): ServiceInvoice
private function getSut(array $paths): InvoiceService
{
$languages = [
'en' => LocaleService::DEFAULT_SETTINGS
@@ -45,7 +48,14 @@ class ServiceInvoiceTest extends TestCase
$repo = new InvoiceDocumentRepository($paths);
$invoiceRepo = $this->createMock(InvoiceRepository::class);
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')), $invoiceRepo, $formattings, (new InvoiceModelFactoryFactory($this))->create());
return new InvoiceService(
$repo,
new FileHelper(realpath(__DIR__ . '/../../var/data/')),
$invoiceRepo,
$formattings,
(new InvoiceModelFactoryFactory($this))->create(),
$this->createMock(EventDispatcherInterface::class)
);
}
public function testInvalidExceptionOnChangeState(): void

View File

@@ -32,6 +32,12 @@ trait KernelTestTrait
return $em;
}
/**
* @template TEntity
* @param TestFixture<TEntity> $fixture
* @return non-empty-array<TEntity>
* @throws \Exception
*/
protected function importFixture(TestFixture $fixture): array
{
return $fixture->load($this->getEntityManager());

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Mocks;
use App\Entity\InvoiceMeta;
use App\Event\InvoiceMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class InvoiceTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
InvoiceMetaDefinitionEvent::class => ['loadMeta', 200],
];
}
public function loadMeta(InvoiceMetaDefinitionEvent $event): void
{
$definition = (new InvoiceMeta())
->setName('metatestmock')
->setType(TextType::class)
->addConstraint(new Length(['max' => 200]))
->setIsVisible(true);
$event->getEntity()->setMetaField($definition);
$definition = (new InvoiceMeta())
->setName('foobar')
->setType(IntegerType::class)
->setIsVisible(false);
$event->getEntity()->setMetaField($definition);
}
}

View File

@@ -9,6 +9,10 @@
namespace App\Tests\Security;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\RolePermissionRepository;
use App\Security\RolePermissionManager;
@@ -115,4 +119,167 @@ class RolePermissionManagerTest extends TestCase
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'role_permissions'));
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'view_user'));
}
public function testCheckTeamAccessCustomerAllowsUsersWithGlobalAccess(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customer->addTeam(new Team('Support'));
$user = new User();
self::assertFalse($sut->checkTeamAccessCustomer($customer, $user));
$user->initCanSeeAllData(true);
self::assertTrue($sut->checkTeamAccessCustomer($customer, $user));
}
public function testCheckTeamAccessCustomerAllowsAccessWithoutAssignedTeams(): void
{
$sut = $this->createSut();
self::assertTrue($sut->checkTeamAccessCustomer(new Customer('Acme'), new User()));
}
public function testCheckTeamAccessCustomerRequiresMembershipForAssignedTeams(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$team = new Team('Support');
$customer->addTeam($team);
$user = new User();
self::assertFalse($sut->checkTeamAccessCustomer($customer, new User()));
self::assertFalse($sut->checkTeamAccessCustomer($customer, $user));
$team->addUser($user);
self::assertTrue($sut->checkTeamAccessCustomer($customer, $user));
}
public function testCheckTeamAccessProjectDeniesAccessIfCustomerIsDenied(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customerTeam = new Team('Customer team');
$customer->addTeam($customerTeam);
$project = new Project();
$project->setCustomer($customer);
$projectTeam = new Team('Project team');
$project->addTeam($projectTeam);
$user = new User();
$projectTeam->addUser($user);
self::assertFalse($sut->checkTeamAccessProject($project, $user));
}
public function testCheckTeamAccessProjectAllowsUsersWithGlobalAccess(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customer->addTeam(new Team('Customer team'));
$project = new Project();
$project->setCustomer($customer);
$project->addTeam(new Team('Project team'));
$user = new User();
self::assertFalse($sut->checkTeamAccessProject($project, $user));
$user->initCanSeeAllData(true);
self::assertTrue($sut->checkTeamAccessProject($project, $user));
}
public function testCheckTeamAccessProjectAllowsMatchingProjectTeamAfterCustomerAccess(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customerTeam = new Team('Customer team');
$customer->addTeam($customerTeam);
$project = new Project();
$project->setCustomer($customer);
$projectTeam = new Team('Project team');
$project->addTeam($projectTeam);
$user = new User();
$customerTeam->addUser($user);
$projectTeam->addUser($user);
self::assertTrue($sut->checkTeamAccessProject($project, $user));
}
public function testCheckTeamAccessActivityDeniesAccessIfProjectIsDenied(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customerTeam = new Team('Customer team');
$customer->addTeam($customerTeam);
$project = new Project();
$project->setCustomer($customer);
$projectTeam = new Team('Project team');
$project->addTeam($projectTeam);
$activity = new Activity();
$activity->setProject($project);
$activityTeam = new Team('Activity team');
$activity->addTeam($activityTeam);
$user = new User();
$activityTeam->addUser($user);
self::assertFalse($sut->checkTeamAccessActivity($activity, $user));
}
public function testCheckTeamAccessActivityAllowsUsersWithGlobalAccess(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customer->addTeam(new Team('Customer team'));
$project = new Project();
$project->setCustomer($customer);
$project->addTeam(new Team('Project team'));
$activity = new Activity();
$activity->setProject($project);
$activity->addTeam(new Team('Activity team'));
$user = new User();
self::assertFalse($sut->checkTeamAccessActivity($activity, $user));
$user->initCanSeeAllData(true);
self::assertTrue($sut->checkTeamAccessActivity($activity, $user));
}
public function testCheckTeamAccessActivityAllowsMatchingActivityTeamAfterProjectAccess(): void
{
$sut = $this->createSut();
$customer = new Customer('Acme');
$customerTeam = new Team('Customer team');
$customer->addTeam($customerTeam);
$project = new Project();
$project->setCustomer($customer);
$projectTeam = new Team('Project team');
$project->addTeam($projectTeam);
$activity = new Activity();
$activity->setProject($project);
$activityTeam = new Team('Activity team');
$activity->addTeam($activityTeam);
$user = new User();
$customerTeam->addUser($user);
$projectTeam->addUser($user);
$activityTeam->addUser($user);
self::assertTrue($sut->checkTeamAccessActivity($activity, $user));
}
private function createSut(): RolePermissionManager
{
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$repository->method('getAllAsArray')->willReturn([]);
return new RolePermissionManager(new PermissionService($repository, new ArrayAdapter()), [], []);
}
}

View File

@@ -0,0 +1,134 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\Team;
use App\Entity\User;
use App\Voter\InvoiceVoter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
#[CoversClass(InvoiceVoter::class)]
class InvoiceVoterTest extends AbstractVoterTestCase
{
#[DataProvider('getVoteData')]
public function testVote(User $user, mixed $subject, string $attribute, int $result): void
{
$this->assertVote($user, $subject, $attribute, $result);
}
public function testVoteDeniesIfTokenHasNoApplicationUser(): void
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$sut = $this->getVoter(InvoiceVoter::class);
self::assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $this->createInvoice(), ['view_invoice']));
}
public function testVoteDeniesIfInvoiceHasNoCustomer(): void
{
$this->assertVote(self::getUser(2, User::ROLE_TEAMLEAD), new Invoice(), 'view_invoice', VoterInterface::ACCESS_DENIED);
}
public function testVoteRequiresCustomerTeamAccess(): void
{
$customer = new Customer('Acme');
$customer->addTeam(new Team('Accounting'));
$invoice = $this->createInvoice($customer);
$this->assertVote(self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED);
$team = new Team('Accounting');
$user = new User();
$user->addRole(User::ROLE_TEAMLEAD);
$team->addTeamlead($user);
$customer = new Customer('Acme');
$customer->addTeam($team);
$invoice = $this->createInvoice($customer);
$this->assertVote($user, $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED);
$this->assertVote($user, $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED);
}
public function testDeleteInvoiceRequiresDeletePermission(): void
{
$permissions = [
'ROLE_TEAMLEAD' => ['view_invoice', 'create_invoice', 'delete_invoice'],
];
$team = new Team('Accounting');
$user = new User();
$user->addRole(User::ROLE_TEAMLEAD);
$team->addTeamlead($user);
$customer = new Customer('Acme');
$customer->addTeam($team);
$token = new UsernamePasswordToken($user, 'bar', $user->getRoles());
$sut = new InvoiceVoter($this->getRolePermissionManager($permissions, true));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $this->createInvoice($customer), ['delete_invoice']));
}
public static function getVoteData(): \Generator
{
$invoice = self::createStaticInvoice();
yield [self::getUser(0, 'foo'), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED];
yield [self::getUser(1, User::ROLE_USER), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED];
yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED];
yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED];
yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED];
yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED];
$result = VoterInterface::ACCESS_ABSTAIN;
yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view', $result];
yield [self::getUser(2, User::ROLE_TEAMLEAD), new \stdClass(), 'view_invoice', $result];
yield [self::getUser(2, User::ROLE_TEAMLEAD), null, 'edit_invoice', $result];
}
private function assertVote(User $user, mixed $subject, string $attribute, int $result): void
{
$token = new UsernamePasswordToken($user, 'bar', $user->getRoles());
$sut = $this->getVoter(InvoiceVoter::class);
self::assertEquals($result, $sut->vote($token, $subject, [$attribute]));
}
private function createInvoice(?Customer $customer = null): Invoice
{
$invoice = new Invoice();
if ($customer !== null) {
$invoice->setCustomer($customer);
}
return $invoice;
}
private static function createStaticInvoice(): Invoice
{
$invoice = new Invoice();
$invoice->setCustomer(new Customer('Acme'));
return $invoice;
}
}

View File

@@ -356,11 +356,6 @@ parameters:
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: Command/ExportCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\InvoiceCreateCommandTest\\:\\:assertCommandErrors\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
@@ -371,11 +366,6 @@ parameters:
count: 1
path: Command/InvoiceCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\InvoiceCreateCommandTest\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: Command/InvoiceCreateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
@@ -526,11 +516,6 @@ parameters:
count: 1
path: Controller/AbstractControllerBaseTestCase.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: Controller/AbstractControllerBaseTestCase.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#"
count: 1
@@ -991,11 +976,6 @@ parameters:
count: 3
path: Controller/WidgetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TestFixture\\:\\:load\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: DataFixtures/TestFixture.php
-
message: "#^Cannot access offset 'foo\\-bundle\\.bar' on array\\|bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -1569,17 +1549,17 @@ parameters:
-
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 4
path: Invoice/ServiceInvoiceTest.php
path: Invoice/InvoiceServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\ServiceInvoiceTest\\:\\:getSut\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#"
message: "#^Method App\\\\Tests\\\\Invoice\\\\InvoiceServiceTest\\:\\:getSut\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#"
count: 1
path: Invoice/ServiceInvoiceTest.php
path: Invoice/InvoiceServiceTest.php
-
message: "#^Parameter \\#1 \\$dataDir of class App\\\\Utils\\\\FileHelper constructor expects string, string\\|false given\\.$#"
count: 1
path: Invoice/ServiceInvoiceTest.php
path: Invoice/InvoiceServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Ldap\\\\LdapManagerTest\\:\\:getLdapManager\\(\\) has no return type specified\\.$#"
@@ -1701,11 +1681,6 @@ parameters:
count: 1
path: Repository/AbstractRepositoryTestCase.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\AbstractRepositoryTestCase\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: Repository/AbstractRepositoryTestCase.php
-
message: "#^Parameter \\#1 \\$directory of method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:addDirectory\\(\\) expects string, string\\|false given\\.$#"
count: 1