diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index d97b83a8..28ca088c 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -21,8 +21,11 @@ nelmio_api_doc: - { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } + - { alias: UserCreateForm, type: App\Form\API\UserApiCreateForm, groups: [Default, Entity, User, User_Entity] } + - { alias: UserEditForm, type: App\Form\API\UserApiEditForm, groups: [Default, Entity, User, User_Entity] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] } - { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } + - { alias: TeamEditForm, type: App\Form\API\TeamApiEditForm, groups: [Default, Entity, Team] } - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] } - { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } diff --git a/config/serializer/App/Entity.Customer.yml b/config/serializer/App/Entity.Customer.yml index fff757e6..6d859783 100644 --- a/config/serializer/App/Entity.Customer.yml +++ b/config/serializer/App/Entity.Customer.yml @@ -1,6 +1,6 @@ App\Entity\Customer: exclusion_policy: All - custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate, color, budget, timeBudget, metaFields] + custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, teams] properties: id: include: true @@ -63,6 +63,9 @@ App\Entity\Customer: include: true metaFields: exclude: true + teams: + include: true + groups: [Customer] virtual_properties: getMetaFields: serialized_name: metaFields diff --git a/config/serializer/App/Entity.Project.yml b/config/serializer/App/Entity.Project.yml index a4b55e5b..4ba95ac6 100644 --- a/config/serializer/App/Entity.Project.yml +++ b/config/serializer/App/Entity.Project.yml @@ -1,6 +1,6 @@ App\Entity\Project: exclusion_policy: All - custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle] + custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle, teams] properties: id: include: true @@ -41,6 +41,9 @@ App\Entity\Project: include: true metaFields: exclude: true + teams: + include: true + groups: [Project] virtual_properties: parentTitle: serialized_name: parentTitle diff --git a/config/serializer/App/Entity.Team.yml b/config/serializer/App/Entity.Team.yml index e177e607..57bbf650 100644 --- a/config/serializer/App/Entity.Team.yml +++ b/config/serializer/App/Entity.Team.yml @@ -1,6 +1,6 @@ App\Entity\Team: exclusion_policy: All - custom_accessor_order: [id, name, teamlead, users] + custom_accessor_order: [id, name, teamlead, users, customers, projects] properties: id: include: true @@ -13,6 +13,8 @@ App\Entity\Team: include: true groups: [Team_Entity] customers: - exclude: true + include: true + groups: [Team_Entity] projects: - exclude: true + include: true + groups: [Team_Entity] diff --git a/src/API/TeamController.php b/src/API/TeamController.php index 62b71315..021b9232 100644 --- a/src/API/TeamController.php +++ b/src/API/TeamController.php @@ -11,8 +11,15 @@ declare(strict_types=1); namespace App\API; +use App\Entity\Customer; +use App\Entity\Project; use App\Entity\Team; +use App\Entity\User; +use App\Form\API\TeamApiEditForm; +use App\Repository\CustomerRepository; +use App\Repository\ProjectRepository; use App\Repository\TeamRepository; +use App\Repository\UserRepository; use FOS\RestBundle\Controller\Annotations\RouteResource; use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View; @@ -20,23 +27,25 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * @RouteResource("Team") * * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')") */ -class TeamController extends BaseApiController +final class TeamController extends BaseApiController { /** * @var TeamRepository */ - protected $repository; + private $repository; /** * @var ViewHandlerInterface */ - protected $viewHandler; + private $viewHandler; public function __construct(ViewHandlerInterface $viewHandler, TeamRepository $repository) { @@ -135,4 +144,481 @@ class TeamController extends BaseApiController return $this->viewHandler->handle($view); } + + /** + * Creates a new team + * + * @SWG\Post( + * description="Creates a new team and returns it afterwards", + * @SWG\Response( + * response=200, + * description="Returns the new created team", + * @SWG\Schema(ref="#/definitions/TeamEntity",), + * ) + * ) + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema(ref="#/definitions/TeamEditForm") + * ) + * + * @Security("is_granted('create_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function postAction(Request $request): Response + { + $team = new Team(); + $team->setTeamLead($this->getUser()); + + $form = $this->createForm(TeamApiEditForm::class, $team); + + $form->submit($request->request->all()); + $team->addUser($team->getTeamLead()); + + if ($form->isValid()) { + $this->repository->saveTeam($team); + + $view = new View($team, 200); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + $view = new View($form); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Update an existing team + * + * @SWG\Patch( + * description="Update an existing team, you can pass all or just a subset of all attributes (passing users will replace all existing ones)", + * @SWG\Response( + * response=200, + * description="Returns the updated team", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema(ref="#/definitions/TeamEditForm") + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="Team ID to update", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function patchAction(Request $request, int $id): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException(); + } + + $form = $this->createForm(TeamApiEditForm::class, $team); + + $form->setData($team); + $form->submit($request->request->all(), false); + $team->addUser($team->getTeamLead()); + + if (false === $form->isValid()) { + $view = new View($form, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Add a new member to a team + * + * @SWG\Post( + * @SWG\Response( + * response=200, + * description="Adds a new user to a team. The user must not be deactivated.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team which will receive the new member", + * required=true, + * ) + * @SWG\Parameter( + * name="userId", + * in="path", + * type="integer", + * description="The team member to add (User ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function postMemberAction(int $id, int $userId, UserRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var User $user */ + $user = $repository->find($userId); + + if (null === $user) { + throw new NotFoundException('User not found'); + } + + if (!$user->isEnabled()) { + throw new BadRequestHttpException('Cannot add disabled user to team'); + } + + if ($user->isInTeam($team)) { + throw new BadRequestHttpException('User is already member of the team'); + } + + $team->addUser($user); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Removes a member from the team + * + * @SWG\Delete( + * @SWG\Response( + * response=200, + * description="Removes a user from the team. The teamlead cannot be removed.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team from which the member will be removed", + * required=true, + * ) + * @SWG\Parameter( + * name="userId", + * in="path", + * type="integer", + * description="The team member to remove (User ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function deleteMemberAction(int $id, int $userId, UserRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var User $user */ + $user = $repository->find($userId); + + if (null === $user) { + throw new NotFoundException('User not found'); + } + + if (!$user->isInTeam($team)) { + throw new BadRequestHttpException('User is not a member of the team'); + } + + if ($team->isTeamlead($user)) { + throw new BadRequestHttpException('Cannot remove teamlead'); + } + + $team->removeUser($user); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Grant the team access to a customer + * + * @SWG\Post( + * @SWG\Response( + * response=200, + * description="Adds a new customer to a team. The customer must not be invisible.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team that is granted access", + * required=true, + * ) + * @SWG\Parameter( + * name="customerId", + * in="path", + * type="integer", + * description="The customer to grant acecess to (Customer ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function postCustomerAction(int $id, int $customerId, CustomerRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var Customer $customer */ + $customer = $repository->find($customerId); + + if (null === $customer) { + throw new NotFoundException('Customer not found'); + } + + if (!$customer->isVisible()) { + throw new BadRequestHttpException('Cannot grant access to an invisible customer'); + } + + if ($team->hasCustomer($customer)) { + throw new BadRequestHttpException('Team has already access to customer'); + } + + $team->addCustomer($customer); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Revokes access for a customer from a team + * + * @SWG\Delete( + * @SWG\Response( + * response=200, + * description="Removes a customer from the team.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team whose permission will be revoked", + * required=true, + * ) + * @SWG\Parameter( + * name="customerId", + * in="path", + * type="integer", + * description="The customer to remove (Customer ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function deleteCustomerAction(int $id, int $customerId, CustomerRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var Customer $customer */ + $customer = $repository->find($customerId); + + if (null === $customer) { + throw new NotFoundException('Customer not found'); + } + + if (!$team->hasCustomer($customer)) { + throw new BadRequestHttpException('Customer is not assigned to the team'); + } + + $team->removeCustomer($customer); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Grant the team access to a project + * + * @SWG\Post( + * @SWG\Response( + * response=200, + * description="Adds a new project to a team. The project must not be invisible.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team that is granted access", + * required=true, + * ) + * @SWG\Parameter( + * name="projectId", + * in="path", + * type="integer", + * description="The project to grant acecess to (Project ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function postProjectAction(int $id, int $projectId, ProjectRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var Project $project */ + $project = $repository->find($projectId); + + if (null === $project) { + throw new NotFoundException('Project not found'); + } + + if (!$project->isVisible()) { + throw new BadRequestHttpException('Cannot grant access to an invisible project'); + } + + if ($team->hasProject($project)) { + throw new BadRequestHttpException('Team has already access to project'); + } + + $team->addProject($project); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Revokes access for a project from a team + * + * @SWG\Delete( + * @SWG\Response( + * response=200, + * description="Removes a project from the team.", + * @SWG\Schema(ref="#/definitions/TeamEntity") + * ) + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="The team whose permission will be revoked", + * required=true, + * ) + * @SWG\Parameter( + * name="projectId", + * in="path", + * type="integer", + * description="The project to remove (Project ID)", + * required=true, + * ) + * + * @Security("is_granted('edit_team')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function deleteProjectAction(int $id, int $projectId, ProjectRepository $repository): Response + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException('Team not found'); + } + + /** @var Project $project */ + $project = $repository->find($projectId); + + if (null === $project) { + throw new NotFoundException('Project not found'); + } + + if (!$team->hasProject($project)) { + throw new BadRequestHttpException('Project is not assigned to the team'); + } + + $team->removeProject($project); + + $this->repository->saveTeam($team); + + $view = new View($team, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'Team_Entity']); + + return $this->viewHandler->handle($view); + } } diff --git a/src/API/UserController.php b/src/API/UserController.php index 7efc190d..8d9c22fc 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -11,6 +11,10 @@ declare(strict_types=1); namespace App\API; +use App\Configuration\FormConfiguration; +use App\Entity\User; +use App\Form\API\UserApiCreateForm; +use App\Form\API\UserApiEditForm; use App\Repository\Query\UserQuery; use App\Repository\UserRepository; use App\Utils\SearchTerm; @@ -22,34 +26,45 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; /** * @RouteResource("User") * * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')") */ -class UserController extends BaseApiController +final class UserController extends BaseApiController { /** * @var UserRepository */ - protected $repository; - + private $repository; /** * @var ViewHandlerInterface */ - protected $viewHandler; + private $viewHandler; + /** + * @var UserPasswordEncoderInterface + */ + private $encoder; + /** + * @var FormConfiguration + */ + private $configuration; /** * @param ViewHandlerInterface $viewHandler * @param UserRepository $repository */ - public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository) + public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository, UserPasswordEncoderInterface $encoder, FormConfiguration $config) { $this->viewHandler = $viewHandler; $this->repository = $repository; + $this->encoder = $encoder; + $this->configuration = $config; } /** @@ -159,4 +174,125 @@ class UserController extends BaseApiController return $this->viewHandler->handle($view); } + + /** + * Creates a new user + * + * @SWG\Post( + * description="Creates a new user and returns it afterwards", + * @SWG\Response( + * response=200, + * description="Returns the new created user", + * @SWG\Schema(ref="#/definitions/UserEntity",), + * ) + * ) + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema(ref="#/definitions/UserCreateForm") + * ) + * + * @Security("is_granted('create_user')") + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function postAction(Request $request): Response + { + $user = new User(); + $user->setEnabled(true); + $user->setRoles([User::DEFAULT_ROLE]); + $user->setTimezone($this->configuration->getUserDefaultTimezone()); + $user->setLanguage($this->configuration->getUserDefaultLanguage()); + + $form = $this->createForm(UserApiCreateForm::class, $user, [ + 'include_roles' => $this->isGranted('roles', $user), + 'include_active_flag' => true, + 'include_preferences' => $this->isGranted('preferences', $user), + ]); + + $form->submit($request->request->all()); + + if ($form->isValid()) { + $password = $this->encoder->encodePassword($user, $user->getPlainPassword()); + $user->setPassword($password); + + $this->repository->saveUser($user); + + $view = new View($user, 200); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + + return $this->viewHandler->handle($view); + } + + $view = new View($form); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + + return $this->viewHandler->handle($view); + } + + /** + * Update an existing user + * + * @SWG\Patch( + * description="Update an existing user, you can pass all or just a subset of all attributes (passing roles will replace all existing ones)", + * @SWG\Response( + * response=200, + * description="Returns the updated user", + * @SWG\Schema(ref="#/definitions/UserEntity") + * ) + * ) + * @SWG\Parameter( + * name="body", + * in="body", + * required=true, + * @SWG\Schema(ref="#/definitions/UserEditForm") + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="User ID to update", + * required=true, + * ) + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function patchAction(Request $request, int $id): Response + { + $user = $this->repository->getUserById($id); + + if (null === $user) { + throw new NotFoundException(); + } + + if (!$this->isGranted('edit', $user)) { + throw new AccessDeniedHttpException('Not allowed to edit user'); + } + + $form = $this->createForm(UserApiEditForm::class, $user, [ + 'include_roles' => $this->isGranted('roles', $user), + 'include_active_flag' => ($user->getId() !== $this->getUser()->getId()), + 'include_preferences' => $this->isGranted('preferences', $user), + ]); + + $form->setData($user); + $form->submit($request->request->all(), false); + + if (false === $form->isValid()) { + $view = new View($form, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + + return $this->viewHandler->handle($view); + } + + $this->repository->saveUser($user); + + $view = new View($user, Response::HTTP_OK); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); + + return $this->viewHandler->handle($view); + } } diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index e1a88264..8401875c 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -20,7 +20,6 @@ use App\Form\UserRolesType; use App\Form\UserTeamsType; use App\Repository\TeamRepository; use App\Repository\TimesheetRepository; -use App\Voter\UserVoter; use Doctrine\Common\Collections\ArrayCollection; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -281,23 +280,23 @@ class ProfileController extends AbstractController ): Response { $forms = []; - if ($this->isGranted(UserVoter::EDIT, $user)) { + if ($this->isGranted('edit', $user)) { $editForm = $editForm ?: $this->createEditForm($user); $forms['settings'] = $editForm->createView(); } - if ($this->isGranted(UserVoter::PASSWORD, $user)) { + if ($this->isGranted('password', $user)) { $pwdForm = $pwdForm ?: $this->createPasswordForm($user); $forms['password'] = $pwdForm->createView(); } - if ($this->isGranted(UserVoter::API_TOKEN, $user)) { + if ($this->isGranted('api-token', $user)) { $apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user); $forms['api-token'] = $apiTokenForm->createView(); } - if ($this->isGranted(UserVoter::TEAMS, $user) && $this->teams->count([]) > 0) { + if ($this->isGranted('teams', $user) && $this->teams->count([]) > 0) { $teamsForm = $teamsForm ?: $this->createTeamsForm($user); $forms['teams'] = $teamsForm->createView(); } - if ($this->isGranted(UserVoter::ROLES, $user)) { + if ($this->isGranted('roles', $user)) { $rolesForm = $rolesForm ?: $this->createRolesForm($user); $forms['roles'] = $rolesForm->createView(); } @@ -329,7 +328,8 @@ class ProfileController extends AbstractController [ 'action' => $this->generateUrl('user_profile_edit', ['username' => $user->getUsername()]), 'method' => 'POST', - 'include_active_flag' => ($user->getId() !== $this->getUser()->getId()) + 'include_active_flag' => ($user->getId() !== $this->getUser()->getId()), + 'include_preferences' => false, ] ); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index a600c2af..309c48b1 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -9,6 +9,7 @@ namespace App\Controller; +use App\Configuration\FormConfiguration; use App\Entity\User; use App\Event\UserPreferenceDisplayEvent; use App\Form\Toolbar\UserToolbarForm; @@ -31,20 +32,20 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * @Route(path="/admin/user") * @Security("is_granted('view_user')") */ -class UserController extends AbstractController +final class UserController extends AbstractController { /** * @var UserPasswordEncoderInterface */ - protected $encoder; + private $encoder; /** * @var UserRepository */ - protected $repository; + private $repository; /** * @var EventDispatcherInterface */ - protected $dispatcher; + private $dispatcher; public function __construct(UserPasswordEncoderInterface $encoder, UserRepository $repository, EventDispatcherInterface $dispatcher) { @@ -93,23 +94,31 @@ class UserController extends AbstractController ]); } + private function createNewDefaultUser(FormConfiguration $config): User + { + $user = new User(); + $user->setEnabled(true); + $user->setRoles([User::DEFAULT_ROLE]); + $user->setTimezone($config->getUserDefaultTimezone()); + $user->setLanguage($config->getUserDefaultLanguage()); + + return $user; + } + /** * @Route(path="/create", name="admin_user_create", methods={"GET", "POST"}) * @Security("is_granted('create_user')") */ - public function createAction(Request $request): Response + public function createAction(Request $request, FormConfiguration $config): Response { - $user = new User(); - $user->setEnabled(true); - $editForm = $this->createEditForm($user); + $user = $this->createNewDefaultUser($config); + $editForm = $this->getCreateUserForm($user); $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { $password = $this->encoder->encodePassword($user, $user->getPlainPassword()); $user->setPassword($password); - $user->setEnabled(true); - $user->setRoles([User::DEFAULT_ROLE]); $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($user); @@ -121,8 +130,12 @@ class UserController extends AbstractController return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]); } - $user = new User(); - $editForm = $this->createEditForm($user); + $firstUser = $user; + $user = $this->createNewDefaultUser($config); + $user->setLanguage($firstUser->getLanguage()); + $user->setTimezone($firstUser->getTimezone()); + + $editForm = $this->getCreateUserForm($user); $editForm->get('create_more')->setData(true); } @@ -187,12 +200,14 @@ class UserController extends AbstractController ]); } - private function createEditForm(User $user): FormInterface + private function getCreateUserForm(User $user): FormInterface { return $this->createForm(UserCreateType::class, $user, [ 'action' => $this->generateUrl('admin_user_create'), 'method' => 'POST', - 'include_active_flag' => true + 'include_active_flag' => true, + 'include_preferences' => $this->isGranted('preferences', $user), + 'include_add_more' => true, ]); } } diff --git a/src/Entity/Team.php b/src/Entity/Team.php index fb659904..c8aeae78 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -144,6 +144,11 @@ class Team return $this->users; } + public function hasCustomer(Customer $customer): bool + { + return $this->customers->contains($customer); + } + public function addCustomer(Customer $customer) { if ($this->customers->contains($customer)) { @@ -172,6 +177,11 @@ class Team return $this->customers; } + public function hasProject(Project $project): bool + { + return $this->projects->contains($project); + } + public function addProject(Project $project) { if ($this->projects->contains($project)) { diff --git a/src/Entity/User.php b/src/Entity/User.php index b9f77887..6e23d532 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -266,6 +266,27 @@ class User extends BaseUser implements UserInterface return $this->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get()); } + public function getLanguage(): string + { + return $this->getLocale(); + } + + public function setLanguage(?string $language) + { + if ($language === null) { + $language = User::DEFAULT_LANGUAGE; + } + $this->setPreferenceValue(UserPreference::LOCALE, $language); + } + + public function setTimezone(?string $timezone) + { + if ($timezone === null) { + $timezone = date_default_timezone_get(); + } + $this->setPreferenceValue(UserPreference::TIMEZONE, $timezone); + } + /** * @param string $name * @param mixed $default diff --git a/src/Form/API/TeamApiEditForm.php b/src/Form/API/TeamApiEditForm.php new file mode 100644 index 00000000..fd2b71cb --- /dev/null +++ b/src/Form/API/TeamApiEditForm.php @@ -0,0 +1,35 @@ +setDefaults([ + 'expand_users' => false, + 'csrf_protection' => false, + ]); + } +} diff --git a/src/Form/API/UserApiCreateForm.php b/src/Form/API/UserApiCreateForm.php new file mode 100644 index 00000000..50e1f4f6 --- /dev/null +++ b/src/Form/API/UserApiCreateForm.php @@ -0,0 +1,58 @@ +remove('plainPassword'); + + $builder->add('plainPassword', PasswordType::class, [ + 'required' => true, + 'label' => 'label.password', + 'documentation' => [ + 'type' => 'string', + 'description' => 'Plain text password', + ], + ]); + + if ($options['include_roles']) { + $builder->add('roles', UserRoleType::class, [ + 'label' => 'label.roles', + 'required' => false, + 'multiple' => true, + 'expanded' => false, + ]); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + 'include_roles' => true, + 'include_add_more' => false, + ]); + } +} diff --git a/src/Form/API/UserApiEditForm.php b/src/Form/API/UserApiEditForm.php new file mode 100644 index 00000000..ef1a5924 --- /dev/null +++ b/src/Form/API/UserApiEditForm.php @@ -0,0 +1,45 @@ +add('roles', UserRoleType::class, [ + 'label' => 'label.roles', + 'required' => false, + 'multiple' => true, + 'expanded' => false, + ]); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + 'include_roles' => true, + ]); + } +} diff --git a/src/Form/TeamEditForm.php b/src/Form/TeamEditForm.php index 735de8cd..599e1ce1 100644 --- a/src/Form/TeamEditForm.php +++ b/src/Form/TeamEditForm.php @@ -29,15 +29,25 @@ class TeamEditForm extends AbstractType 'attr' => [ 'autofocus' => 'autofocus' ], + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'string', + 'description' => 'Name of the new team', + ], ]) ->add('teamlead', UserType::class, [ 'label' => 'label.teamlead', 'multiple' => false, 'expanded' => false, + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'integer', + 'description' => 'User ID for the teamlead', + ], ]) ->add('users', UserType::class, [ 'multiple' => true, - 'expanded' => true, + 'expanded' => $options['expand_users'], 'by_reference' => false, ]) ; @@ -52,6 +62,7 @@ class TeamEditForm extends AbstractType 'data_class' => Team::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', + 'expand_users' => true, 'csrf_token_id' => 'admin_team_edit', 'attr' => [ 'data-form-event' => 'kimai.teamUpdate' diff --git a/src/Form/UserCreateType.php b/src/Form/UserCreateType.php index 4a42d3a7..f37b5c8d 100644 --- a/src/Form/UserCreateType.php +++ b/src/Form/UserCreateType.php @@ -9,7 +9,6 @@ namespace App\Form; -use App\Entity\User; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; @@ -43,20 +42,21 @@ class UserCreateType extends UserEditType parent::buildForm($builder, $options); - $builder->add('create_more', CheckboxType::class, [ - 'label' => 'label.create_more', - 'required' => false, - 'mapped' => false, - ]); + if ($options['include_add_more'] === true) { + $builder->add('create_more', CheckboxType::class, [ + 'label' => 'label.create_more', + 'required' => false, + 'mapped' => false, + ]); + } } - /** - * {@inheritdoc} - */ - public function __configureOptions(OptionsResolver $resolver) + public function configureOptions(OptionsResolver $resolver) { + parent::configureOptions($resolver); + $resolver->setDefaults([ - 'class' => User::class, + 'include_add_more' => false, ]); } } diff --git a/src/Form/UserEditType.php b/src/Form/UserEditType.php index 2d552900..75385bee 100644 --- a/src/Form/UserEditType.php +++ b/src/Form/UserEditType.php @@ -11,10 +11,12 @@ namespace App\Form; use App\Entity\User; use App\Form\Type\AvatarType; +use App\Form\Type\LanguageType; use App\Form\Type\YesNoType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\TimezoneType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -45,6 +47,16 @@ class UserEditType extends AbstractType ]) ; + if ($options['include_preferences']) { + $builder->add('language', LanguageType::class, [ + 'required' => true, + ]); + + $builder->add('timezone', TimezoneType::class, [ + 'required' => true, + ]); + } + if ($options['include_active_flag']) { $builder ->add('enabled', YesNoType::class, [ @@ -64,7 +76,8 @@ class UserEditType extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'edit_user_profile', - 'include_active_flag' => false, + 'include_active_flag' => true, + 'include_preferences' => true, ]); } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 01558655..3f003dc1 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -17,6 +17,7 @@ use App\Repository\Query\BaseQuery; use App\Repository\Query\UserFormTypeQuery; use App\Repository\Query\UserQuery; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\ORMException; use Doctrine\ORM\QueryBuilder; use Pagerfanta\Adapter\DoctrineORMAdapter; use Pagerfanta\Pagerfanta; @@ -32,7 +33,19 @@ class UserRepository extends EntityRepository implements UserLoaderInterface } /** - * Used to fetch the currently logged-in user. + * @param User $user + * @throws ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function saveUser(User $user) + { + $entityManager = $this->getEntityManager(); + $entityManager->persist($user); + $entityManager->flush(); + } + + /** + * Used to fetch a user by its ID. * * @param int $id * @return null|User diff --git a/src/Voter/TeamVoter.php b/src/Voter/TeamVoter.php index f8b83690..e0c12c2c 100644 --- a/src/Voter/TeamVoter.php +++ b/src/Voter/TeamVoter.php @@ -15,17 +15,13 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; class TeamVoter extends AbstractVoter { - public const VIEW = 'view'; - public const EDIT = 'edit'; - public const DELETE = 'delete'; - /** * support rules based on the given $subject (here: Team) */ public const ALLOWED_ATTRIBUTES = [ - self::VIEW, - self::EDIT, - self::DELETE, + 'view', + 'edit', + 'delete', ]; /** diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php index 1390c290..2714f385 100644 --- a/src/Voter/UserVoter.php +++ b/src/Voter/UserVoter.php @@ -17,26 +17,16 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; */ class UserVoter extends AbstractVoter { - public const VIEW = 'view'; - public const EDIT = 'edit'; - public const DELETE = 'delete'; - public const PASSWORD = 'password'; - public const ROLES = 'roles'; - public const TEAMS = 'teams'; - public const PREFERENCES = 'preferences'; - public const API_TOKEN = 'api-token'; - public const HOURLY_RATE = 'hourly-rate'; - public const ALLOWED_ATTRIBUTES = [ - self::VIEW, - self::EDIT, - self::ROLES, - self::TEAMS, - self::PASSWORD, - self::DELETE, - self::PREFERENCES, - self::API_TOKEN, - self::HOURLY_RATE, + 'view', + 'edit', + 'roles', + 'teams', + 'password', + 'delete', + 'preferences', + 'api-token', + 'hourly-rate', ]; /** @@ -71,39 +61,21 @@ class UserVoter extends AbstractVoter return false; } - $permission = ''; - - switch ($attribute) { - // special case for the UserController - case self::DELETE: - if ($subject->getId() === $user->getId()) { - return false; - } - - return $this->hasRolePermission($user, 'delete_user'); - - case self::VIEW: - case self::EDIT: - case self::PREFERENCES: - case self::PASSWORD: - case self::API_TOKEN: - case self::ROLES: - case self::TEAMS: - case self::HOURLY_RATE: - $permission .= $attribute; - break; - - default: + if ($attribute === 'delete') { + if ($subject->getId() === $user->getId()) { return false; + } + + return $this->hasRolePermission($user, 'delete_user'); } - $permission .= '_'; + $permission = $attribute; // extend me for "team" support later on - if ($subject->getId() == $user->getId()) { - $permission .= 'own'; + if ($subject->getId() === $user->getId()) { + $permission .= '_own'; } else { - $permission .= 'other'; + $permission .= '_other'; } $permission .= '_profile'; diff --git a/tests/API/CustomerControllerTest.php b/tests/API/CustomerControllerTest.php index 210b5d7d..8894ff32 100644 --- a/tests/API/CustomerControllerTest.php +++ b/tests/API/CustomerControllerTest.php @@ -230,7 +230,7 @@ class CustomerControllerTest extends APIControllerBaseTest protected function assertStructure(array $result, $full = true) { $expectedKeys = [ - 'id', 'name', 'visible', 'hourlyRate', 'fixedRate', 'color', 'metaFields' + 'id', 'name', 'visible', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'teams' ]; if ($full) { diff --git a/tests/API/ProjectControllerTest.php b/tests/API/ProjectControllerTest.php index 23feaee6..73c65380 100644 --- a/tests/API/ProjectControllerTest.php +++ b/tests/API/ProjectControllerTest.php @@ -277,7 +277,7 @@ class ProjectControllerTest extends APIControllerBaseTest protected function assertStructure(array $result, $full = true) { $expectedKeys = [ - 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle', 'start', 'end' + 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle', 'start', 'end', 'teams' ]; if ($full) { diff --git a/tests/API/TeamControllerTest.php b/tests/API/TeamControllerTest.php index 5366d0fa..58e8d794 100644 --- a/tests/API/TeamControllerTest.php +++ b/tests/API/TeamControllerTest.php @@ -9,6 +9,8 @@ namespace App\Tests\API; +use App\Entity\Customer; +use App\Entity\Project; use App\Entity\User; use App\Tests\DataFixtures\TeamFixtures; use Symfony\Component\HttpFoundation\Response; @@ -42,7 +44,7 @@ class TeamControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); - $this->assertEquals(2, count($result)); + self::assertEquals(2, count($result)); $this->assertStructure($result[0], false); } @@ -66,6 +68,64 @@ class TeamControllerTest extends APIControllerBaseTest $this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/teams/255', []); } + public function testPostAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + $this->assertNotEmpty($result['id']); + } + + public function testPostActionWithInvalidUser() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $response = $client->getResponse(); + $this->assertFalse($response->isSuccessful()); + self::assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + $json = json_decode($response->getContent(), true); + self::assertEquals('Access denied.', $json['message']); + } + + public function testPatchAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 2, + 'users' => [1, 5, 4] + ]; + $this->request($client, '/api/teams/' . $result['id'], 'PATCH', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + $this->assertNotEmpty($result['id']); + self::assertCount(4, $result['users']); + } + public function testDeleteAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -79,12 +139,443 @@ class TeamControllerTest extends APIControllerBaseTest $this->request($client, '/api/teams/' . $id, 'DELETE'); $this->assertTrue($client->getResponse()->isSuccessful()); - $this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); + self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); $this->assertEmpty($client->getResponse()->getContent()); $this->assertEntityNotFound(User::ROLE_ADMIN, '/api/teams/' . $id); } + public function testPostMemberAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $result['users']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/2', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(2, $result['users']); + } + + public function testPostMemberActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/members/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // user not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('User not found', $json['message']); + + // add user + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/5', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + // cannot add existing member + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/5', 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('User is already member of the team', $json['message']); + + // cannot add disabled user + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/3', 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Cannot add disabled user to team', $json['message']); + } + + public function testDeleteMemberAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(4, $result['users']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/2', 'DELETE'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(3, $result['users']); + } + + public function testDeleteMemberActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/members/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // user not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('User not found', $json['message']); + + // remove user + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/2', 'DELETE'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + // cannot remove non-member + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/2', 'DELETE'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('User is not a member of the team', $json['message']); + + // cannot remove teamlead + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/members/1', 'DELETE'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Cannot remove teamlead', $json['message']); + } + + public function testPostCustomerAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(0, $result['customers']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(1, $result['customers']); + self::assertEquals(1, $result['customers'][0]['id']); + } + + public function testPostCustomerActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/customers/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // customer not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Customer not found', $json['message']); + + // add customer + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $result['customers']); + + // cannot add existing customer + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team has already access to customer', $json['message']); + + $customer = new Customer(); + $customer->setName('foooo'); + $customer->setVisible(false); + $customer->setCountry('DE'); + $customer->setTimezone('Europe/Berlin'); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $em->persist($customer); + $em->flush(); + + // cannot add invisible customer + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/' . $customer->getId(), 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Cannot grant access to an invisible customer', $json['message']); + } + + public function testDeleteCustomerAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(0, $result['customers']); + + // add customer + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $result['customers']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'DELETE'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(0, $result['customers']); + } + + public function testDeleteCustomerActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/customers/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // customer not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Customer not found', $json['message']); + + // cannot remove customer + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'DELETE'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Customer is not assigned to the team', $json['message']); + } + + public function testPostProjectAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(0, $result['projects']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(1, $result['projects']); + self::assertEquals(1, $result['projects'][0]['id']); + } + + public function testPostProjectActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/projects/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // project not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/999', 'POST'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Project not found', $json['message']); + + // add project + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $result['projects']); + + // cannot add existing project + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team has already access to project', $json['message']); + + $customer = new Customer(); + $customer->setName('foooo'); + $customer->setVisible(false); + $customer->setCountry('DE'); + $customer->setTimezone('Europe/Berlin'); + + $project = new Project(); + $project->setName('foooo'); + $project->setVisible(false); + $project->setCustomer($customer); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $em->persist($customer); + $em->persist($project); + $em->flush(); + + // cannot add invisible project + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/' . $project->getId(), 'POST'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Cannot grant access to an invisible project', $json['message']); + } + + public function testDeleteProjectAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(0, $result['projects']); + + // add project + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $result['projects']); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'DELETE'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + self::assertCount(0, $result['projects']); + } + + public function testDeleteProjectActionErrors() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'name' => 'foo', + 'teamlead' => 1, + 'users' => [2, 4, 5] + ]; + $this->request($client, '/api/teams', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + // team not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/999/projects/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Team not found', $json['message']); + + // project not found + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/999', 'DELETE'); + self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Project not found', $json['message']); + + // cannot remove project + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'DELETE'); + self::assertEquals(Response::HTTP_BAD_REQUEST, $client->getResponse()->getStatusCode()); + $json = json_decode($client->getResponse()->getContent(), true); + self::assertEquals('Project is not assigned to the team', $json['message']); + } + protected function assertStructure(array $result, $full = true) { $expectedKeys = [ @@ -93,7 +584,7 @@ class TeamControllerTest extends APIControllerBaseTest if ($full) { $expectedKeys = array_merge($expectedKeys, [ - 'teamlead', 'users' + 'teamlead', 'users', 'customers', 'projects' ]); } @@ -101,6 +592,6 @@ class TeamControllerTest extends APIControllerBaseTest sort($actual); sort($expectedKeys); - $this->assertEquals($expectedKeys, $actual, 'Team structure does not match'); + self::assertEquals($expectedKeys, $actual, 'Team structure does not match'); } } diff --git a/tests/API/UserControllerTest.php b/tests/API/UserControllerTest.php index 672f5f47..64c73982 100644 --- a/tests/API/UserControllerTest.php +++ b/tests/API/UserControllerTest.php @@ -10,6 +10,7 @@ namespace App\Tests\API; use App\Entity\User; +use Symfony\Component\HttpFoundation\Response; /** * @group integration @@ -113,6 +114,106 @@ class UserControllerTest extends APIControllerBaseTest $this->assertStructure($result); } + public function testPostAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'username' => 'foo', + 'email' => 'foo@example.com', + 'avatar' => 'test123', + 'title' => 'asdfghjkl', + 'plainPassword' => 'foo@example.com', + 'enabled' => true, + 'language' => 'ru', + 'timezone' => 'Europe/Paris', + 'roles' => [ + 'ROLE_TEAMLEAD', + 'ROLE_ADMIN' + ], + ]; + $this->request($client, '/api/users', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + $this->assertNotEmpty($result['id']); + self::assertEquals('foo', $result['username']); + self::assertEquals('test123', $result['avatar']); + self::assertEquals('asdfghjkl', $result['title']); + self::assertTrue($result['enabled']); + self::assertEquals('ru', $result['language']); + self::assertEquals('Europe/Paris', $result['timezone']); + self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN'], $result['roles']); + } + + public function testPostActionWithInvalidUser() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'username' => 'foo', + 'email' => 'foo@example.com', + 'plainPassword' => 'foo@example.com', + 'enabled' => true, + 'language' => 'ru', + 'timezone' => 'Europe/Paris', + ]; + $this->request($client, '/api/users', 'POST', [], json_encode($data)); + $response = $client->getResponse(); + $this->assertFalse($response->isSuccessful()); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + $json = json_decode($response->getContent(), true); + $this->assertEquals('Access denied.', $json['message']); + } + + public function testPatchAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'username' => 'foo', + 'email' => 'foo@example.com', + 'avatar' => 'test123', + 'title' => 'asdfghjkl', + 'plainPassword' => 'foo@example.com', + 'enabled' => true, + 'language' => 'ru', + 'timezone' => 'Europe/Paris', + 'roles' => [ + 'ROLE_TEAMLEAD', + 'ROLE_ADMIN' + ], + ]; + $this->request($client, '/api/users', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + $result = json_decode($client->getResponse()->getContent(), true); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $data = [ + 'avatar' => 'test321', + 'title' => 'qwertzui', + 'enabled' => false, + 'language' => 'it', + 'timezone' => 'America/New_York', + 'roles' => [ + 'ROLE_TEAMLEAD', + ], + ]; + $this->request($client, '/api/users/' . $result['id'], 'PATCH', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertStructure($result); + $this->assertNotEmpty($result['id']); + self::assertEquals('foo', $result['username']); + self::assertEquals('test321', $result['avatar']); + self::assertEquals('qwertzui', $result['title']); + self::assertFalse($result['enabled']); + self::assertEquals('it', $result['language']); + self::assertEquals('America/New_York', $result['timezone']); + self::assertEquals(['ROLE_TEAMLEAD'], $result['roles']); + } + protected function assertStructure(array $result, $full = true) { $expectedKeys = ['id', 'username', 'enabled', 'alias']; diff --git a/tests/Entity/TeamTest.php b/tests/Entity/TeamTest.php index ab329bd5..5c7500ab 100644 --- a/tests/Entity/TeamTest.php +++ b/tests/Entity/TeamTest.php @@ -57,8 +57,10 @@ class TeamTest extends TestCase self::assertEmpty($customer->getTeams()); $sut = new Team(); + self::assertFalse($sut->hasCustomer($customer)); $sut->addCustomer($customer); self::assertEquals(1, $sut->getCustomers()->count()); + self::assertTrue($sut->hasCustomer($customer)); $actual = $sut->getCustomers()[0]; self::assertSame($actual, $customer); self::assertSame($sut, $customer->getTeams()[0]); @@ -75,8 +77,10 @@ class TeamTest extends TestCase self::assertEmpty($project->getTeams()); $sut = new Team(); + self::assertFalse($sut->hasProject($project)); $sut->addProject($project); self::assertEquals(1, $sut->getProjects()->count()); + self::assertTrue($sut->hasProject($project)); $actual = $sut->getProjects()[0]; self::assertSame($actual, $project); self::assertSame($sut, $project->getTeams()[0]);