From 7402fd886ed819692b5173304da0ef95de4a1ee9 Mon Sep 17 00:00:00 2001 From: Oliver Bates Date: Mon, 14 Sep 2026 02:51:24 +0200 Subject: [PATCH 1/5] Added API routes POST /users/create and DELETE /users/{user_id} to create and delete users --- src/Controller/Api/ApiController.php | 195 +++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 597b08a1..2d1cf950 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -2,10 +2,12 @@ namespace App\Controller\Api; +use App\Entity\AddressBook; use App\Entity\Calendar; use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; use App\Entity\Principal; +use App\Entity\SchedulingObject; use App\Entity\User; use App\Services\Utils; use Doctrine\Persistence\ManagerRegistry; @@ -14,6 +16,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; #[Route('/api/v1', name: 'api_v1_')] class ApiController extends AbstractController @@ -146,6 +149,198 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, int return $this->json($response, 200); } + /** + * Creates a new user. + * + * @param Request $request The HTTP POST request + * + * @return JsonResponse A JSON response containing the user details if successfull + */ + #[Route('/users/create', name: 'user_create', methods: ['POST'])] + public function createUser(Request $request, ManagerRegistry $doctrine, TranslatorInterface $trans): JsonResponse + { + // Parse JSON body + $data = json_decode($request->getContent(), true); + if (JSON_ERROR_NONE !== json_last_error()) { + return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); + } + + $userName = $data['name'] ?? null; + if (empty($userName)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Name', 'timestamp' => $this->getTimestamp()], 400); + } + $userDisplayName = $data['display_name'] ?? null; + if (empty($userDisplayName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $userDisplayName)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Display Name', 'timestamp' => $this->getTimestamp()], 400); + } + $userEmail = $data['email'] ?? null; + if (empty($userEmail)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Email', 'timestamp' => $this->getTimestamp()], 400); + } + $userPassword = $data['password'] ?? null; + if (empty($userPassword)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Password', 'timestamp' => $this->getTimestamp()], 400); + } + $userIsAdmin = $data['is_admin'] ?? null; + if (empty($userIsAdmin) || !in_array($userIsAdmin, [true, false, 'true', 'false'], true)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Is Admin', 'timestamp' => $this->getTimestamp()], 400); + } + + $userNameCheck = $doctrine->getRepository(User::class)->findOneBy([ + 'username' => $userName, + ]); + if ($userNameCheck) { + return $this->json(['status' => 'error', 'message' => 'User Name Already Exists', 'timestamp' => $this->getTimestamp()], 400); + } + + if (!$this->validateUsername($userName)) { + return $this->json(['status' => 'error', 'message' => 'Invalid User Name', 'timestamp' => $this->getTimestamp()], 400); + } + + $user = new User(); + $principal = new Principal(); + + $user->setUsername($userName); + + $hash = password_hash($userPassword, PASSWORD_DEFAULT); + $user->setPassword($hash); + + $entityManager = $doctrine->getManager(); + + $principal->setUri($user->getPrincipalUri()); + + $calendarInstance = new CalendarInstance(); + $calendar = new Calendar(); + $calendarInstance->setPrincipalUri($user->getPrincipalUri()) + ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal + ->setDisplayName($trans->trans('default.calendar.title')) + ->setDescription($trans->trans('default.calendar.description', ['user' => $userDisplayName])) + ->setCalendar($calendar); + + // Enable delegation by default + $principalProxyRead = new Principal(); + $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) + ->setIsMain(false); + $entityManager->persist($principalProxyRead); + + $principalProxyWrite = new Principal(); + $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) + ->setIsMain(false); + $entityManager->persist($principalProxyWrite); + + $addressbook = new AddressBook(); + $addressbook->setPrincipalUri($user->getPrincipalUri()) + ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal + ->setDisplayName($trans->trans('default.addressbook.title')) + ->setDescription($trans->trans('default.addressbook.description', ['user' => $userDisplayName])); + $entityManager->persist($calendarInstance); + $entityManager->persist($addressbook); + $entityManager->persist($principal); + + $principal->setDisplayName($userDisplayName) + ->setEmail($userEmail) + ->setIsAdmin($userIsAdmin); + + $entityManager->persist($user); + $entityManager->flush(); + + $response = [ + 'status' => 'success', + 'data' => [ + 'user_id' => $user->getId(), + 'user_name' => $user->getUsername(), + ], + 'timestamp' => $this->getTimestamp(), + ]; + + return $this->json($response, 200); + } + + /** + * Deletes a specific user. + * + * @param Request $request The HTTP POST request + * @param int $userId The ID of the user to delete + * + * @return JsonResponse A JSON response indicating the success or failure of the operation + */ + #[Route('/users/{userId}', name: 'user_delete', methods: ['DELETE'], requirements: ['userId' => '\d+'])] + public function deleteUser(Request $request, int $userId, ManagerRegistry $doctrine): JsonResponse + { + $user = $this->resolveUser($doctrine, $userId); + if (!$user) { + return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); + } + + try { + $entityManager = $doctrine->getManager(); + $entityManager->remove($user); + + $principal = $doctrine->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); + $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); + + $entityManager->remove($principal); + + if ($principalProxyRead) { + $entityManager->remove($principalProxyRead); + } + + if ($principalProxyWrite) { + $entityManager->remove($principalProxyWrite); + } + + $principalUri = $user->getPrincipalUri(); + + // Remove calendars and addressbooks + $calendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); + foreach ($calendars ?? [] as $instance) { + // We're only removing the calendar objects / changes / and calendar if the deleted user is an owner, + // which means that the underlying calendar instance should not have another principal as owner. + $hasDifferentOwner = $doctrine->getRepository(CalendarInstance::class)->hasDifferentOwner($instance->getCalendar()->getId(), $principalUri); + if (!$hasDifferentOwner) { + foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { + $entityManager->remove($object); + } + foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { + $entityManager->remove($change); + } + // We need to remove the shared versions of this calendar, too + foreach ($instance->getCalendar()->getInstances() ?? [] as $instances) { + $entityManager->remove($instances); + } + $entityManager->remove($instance->getCalendar()); + } + $entityManager->remove($instance); + } + $calendarsSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); + foreach ($calendarsSubscriptions ?? [] as $subscription) { + $entityManager->remove($subscription); + } + $schedulingObjects = $doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($principalUri); + foreach ($schedulingObjects ?? [] as $object) { + $entityManager->remove($object); + } + + $addressbooks = $doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); + foreach ($addressbooks ?? [] as $addressbook) { + foreach ($addressbook->getCards() ?? [] as $card) { + $entityManager->remove($card); + } + foreach ($addressbook->getChanges() ?? [] as $change) { + $entityManager->remove($change); + } + $entityManager->remove($addressbook); + } + + $entityManager->flush(); + } catch (\Exception $e) { + return $this->json(['status' => 'error', 'message' => 'Failed to Delete User', 'timestamp' => $this->getTimestamp()], 500); + } + + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); + } + /** * Retrieves a list of calendars for a specific user, including user calendars, shared calendars, and subscriptions. * From afcac1bce26c9abed2adaaed36b68e599ca083c2 Mon Sep 17 00:00:00 2001 From: Oliver Bates Date: Wed, 23 Sep 2026 08:45:21 +0200 Subject: [PATCH 2/5] Addressed comments, tests still to do --- src/Controller/Admin/UserController.php | 139 +++++--------------- src/Controller/Api/ApiController.php | 165 +++++------------------- src/Services/Utils.php | 107 ++++++++++++++- translations/messages+intl-icu.de.xlf | 6 +- translations/messages+intl-icu.en.xlf | 6 +- translations/messages+intl-icu.fr.xliff | 6 +- 6 files changed, 174 insertions(+), 255 deletions(-) diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index 4943bfca..7d94d191 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -2,12 +2,7 @@ namespace App\Controller\Admin; -use App\Entity\AddressBook; -use App\Entity\Calendar; -use App\Entity\CalendarInstance; -use App\Entity\CalendarSubscription; use App\Entity\Principal; -use App\Entity\SchedulingObject; use App\Entity\User; use App\Form\UserType; use App\Services\Utils; @@ -37,23 +32,28 @@ public function users(ManagerRegistry $doctrine): Response #[Route('/edit/{userId}', name: 'edit')] public function userCreate(ManagerRegistry $doctrine, Utils $utils, Request $request, ?int $userId, TranslatorInterface $trans): Response { - if ($userId) { + $new_user = is_null($userId) ? false : true; + if ($new_user) { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $oldHash = $user->getPassword(); $principal = $doctrine->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + if (!$principal) { + throw $this->createNotFoundException('Principal not found'); + } } else { $user = new User(); - $principal = new Principal(); } - $form = $this->createForm(UserType::class, $user, ['new' => !$userId]); + $form = $this->createForm(UserType::class, $user, ['new' => !$new_user]); - $form->get('displayName')->setData($principal->getDisplayName()); - $form->get('email')->setData($principal->getEmail()); - $form->get('isAdmin')->setData($principal->getIsAdmin()); + if ($new_user) { + $form->get('displayName')->setData($principal->getDisplayName()); + $form->get('email')->setData($principal->getEmail()); + $form->get('isAdmin')->setData($principal->getIsAdmin()); + } $form->handleRequest($request); @@ -63,7 +63,7 @@ public function userCreate(ManagerRegistry $doctrine, Utils $utils, Request $req $isAdmin = $form->get('isAdmin')->getData(); // Create password for user - if ($userId && is_null($user->getPassword())) { + if ($new_user && is_null($user->getPassword())) { // The user is not new and does not want to change its password $user->setPassword($oldHash); } else { @@ -71,45 +71,18 @@ public function userCreate(ManagerRegistry $doctrine, Utils $utils, Request $req $user->setPassword($hash); } - $entityManager = $doctrine->getManager(); - - // If it's a new user, create default calendar and address book, and principal - if (null === $user->getId()) { - $principal->setUri($user->getPrincipalUri()); - - $calendarInstance = new CalendarInstance(); - $calendar = new Calendar(); - $calendarInstance->setPrincipalUri($user->getPrincipalUri()) - ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal - ->setDisplayName($trans->trans('default.calendar.title')) - ->setDescription($trans->trans('default.calendar.description', ['user' => $displayName])) - ->setCalendar($calendar); - - // Enable delegation by default - $principalProxyRead = new Principal(); - $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) - ->setIsMain(false); - $entityManager->persist($principalProxyRead); - - $principalProxyWrite = new Principal(); - $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) - ->setIsMain(false); - $entityManager->persist($principalProxyWrite); - - $addressbook = new AddressBook(); - $addressbook->setPrincipalUri($user->getPrincipalUri()) - ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal - ->setDisplayName($trans->trans('default.addressbook.title')) - ->setDescription($trans->trans('default.addressbook.description', ['user' => $displayName])); - $entityManager->persist($calendarInstance); - $entityManager->persist($addressbook); - $entityManager->persist($principal); + // If it's a new user, create default objects, otherwise set the new values + if (!$new_user) { + $username = $form->get('username')->getData(); + $user->setUsername($username); + $utils->createDefaultObjectsForUser($user, $displayName, $email, $isAdmin); + } else { + $principal->setDisplayName($displayName) + ->setEmail($email) + ->setIsAdmin($isAdmin); } - $principal->setDisplayName($displayName) - ->setEmail($email) - ->setIsAdmin($isAdmin); - + $entityManager = $doctrine->getManager(); $entityManager->persist($user); $entityManager->flush(); @@ -126,75 +99,21 @@ public function userCreate(ManagerRegistry $doctrine, Utils $utils, Request $req } #[Route('/delete/{userId}', name: 'delete', methods: ['POST'])] - public function userDelete(ManagerRegistry $doctrine, Request $request, #[MapEntity(id: 'userId')] User $user, int $userId, TranslatorInterface $trans): Response + public function userDelete(ManagerRegistry $doctrine, Request $request, #[MapEntity(id: 'userId')] User $user, int $userId, TranslatorInterface $trans, Utils $utils): Response { if (!$this->isCsrfTokenValid('admin_action', $request->getPayload()->getString('_token'))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } $entityManager = $doctrine->getManager(); - $entityManager->remove($user); - - $principal = $doctrine->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); - $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); - $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); - - $entityManager->remove($principal); - - if ($principalProxyRead) { - $entityManager->remove($principalProxyRead); - } - - if ($principalProxyWrite) { - $entityManager->remove($principalProxyWrite); - } - - $principalUri = $user->getPrincipalUri(); - - // Remove calendars and addressbooks - $calendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUriWithCalendars($principalUri); - foreach ($calendars ?? [] as $instance) { - // We're only removing the calendar objects / changes / and calendar if the deleted user is an owner, - // which means that the underlying calendar instance should not have another principal as owner. - $hasDifferentOwner = $doctrine->getRepository(CalendarInstance::class)->hasDifferentOwner($instance->getCalendar()->getId(), $principalUri); - if (!$hasDifferentOwner) { - foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { - $entityManager->remove($object); - } - foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { - $entityManager->remove($change); - } - // We need to remove the shared versions of this calendar, too - foreach ($instance->getCalendar()->getInstances() ?? [] as $instances) { - $entityManager->remove($instances); - } - $entityManager->remove($instance->getCalendar()); - } - $entityManager->remove($instance); - } - $calendarsSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); - foreach ($calendarsSubscriptions ?? [] as $subscription) { - $entityManager->remove($subscription); - } - $schedulingObjects = $doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($principalUri); - foreach ($schedulingObjects ?? [] as $object) { - $entityManager->remove($object); - } - - $addressbooks = $doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); - foreach ($addressbooks ?? [] as $addressbook) { - foreach ($addressbook->getCards() ?? [] as $card) { - $entityManager->remove($card); - } - foreach ($addressbook->getChanges() ?? [] as $change) { - $entityManager->remove($change); - } - $entityManager->remove($addressbook); + try { + $utils->deleteUser($user); + $entityManager->flush(); + $this->addFlash('success', $trans->trans('user.deleted')); + } catch (\Exception $e) { + $this->addFlash('error', $trans->trans('user.deleted.error')); } - $entityManager->flush(); - $this->addFlash('success', $trans->trans('user.deleted')); - return $this->redirectToRoute('user_index'); } diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 2d1cf950..c8c8ee7a 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -2,12 +2,10 @@ namespace App\Controller\Api; -use App\Entity\AddressBook; use App\Entity\Calendar; use App\Entity\CalendarInstance; use App\Entity\CalendarSubscription; use App\Entity\Principal; -use App\Entity\SchedulingObject; use App\Entity\User; use App\Services\Utils; use Doctrine\Persistence\ManagerRegistry; @@ -16,6 +14,8 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\TranslatorInterface; #[Route('/api/v1', name: 'api_v1_')] @@ -157,7 +157,7 @@ public function getUserDetails(Request $request, ManagerRegistry $doctrine, int * @return JsonResponse A JSON response containing the user details if successfull */ #[Route('/users/create', name: 'user_create', methods: ['POST'])] - public function createUser(Request $request, ManagerRegistry $doctrine, TranslatorInterface $trans): JsonResponse + public function createUser(Request $request, ManagerRegistry $doctrine, TranslatorInterface $trans, ValidatorInterface $validator, Utils $utils): JsonResponse { // Parse JSON body $data = json_decode($request->getContent(), true); @@ -165,83 +165,36 @@ public function createUser(Request $request, ManagerRegistry $doctrine, Translat return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } - $userName = $data['name'] ?? null; - if (empty($userName)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Name', 'timestamp' => $this->getTimestamp()], 400); + $username = $data['name'] ?? null; + if (is_null($username) || !$this->validateUsername($userName)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Username', 'timestamp' => $this->getTimestamp()], 400); } - $userDisplayName = $data['display_name'] ?? null; - if (empty($userDisplayName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $userDisplayName)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Display Name', 'timestamp' => $this->getTimestamp()], 400); + $display_name = $data['display_name'] ?? null; + if (empty($display_name)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Display Name', 'timestamp' => $this->getTimestamp()], 400); } - $userEmail = $data['email'] ?? null; - if (empty($userEmail)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Email', 'timestamp' => $this->getTimestamp()], 400); + $email = $data['email'] ?? null; + if (empty($email) || count($validator->validate($email, new Assert\Email())) > 0) { + return $this->json(['status' => 'error', 'message' => 'Invalid Email', 'timestamp' => $this->getTimestamp()], 400); } - $userPassword = $data['password'] ?? null; - if (empty($userPassword)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Password', 'timestamp' => $this->getTimestamp()], 400); + $password = $data['password'] ?? null; + if (is_null($password)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Password', 'timestamp' => $this->getTimestamp()], 400); } - $userIsAdmin = $data['is_admin'] ?? null; - if (empty($userIsAdmin) || !in_array($userIsAdmin, [true, false, 'true', 'false'], true)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Is Admin', 'timestamp' => $this->getTimestamp()], 400); + $isAdmin = $data['is_admin'] ?? false; + if (!in_array($isAdmin, [true, false, 'true', 'false'], true)) { + return $this->json(['status' => 'error', 'message' => 'Invalid Is Admin', 'timestamp' => $this->getTimestamp()], 400); } - $userNameCheck = $doctrine->getRepository(User::class)->findOneBy([ - 'username' => $userName, - ]); - if ($userNameCheck) { - return $this->json(['status' => 'error', 'message' => 'User Name Already Exists', 'timestamp' => $this->getTimestamp()], 400); + $existingUsername = $doctrine->getRepository(User::class)->findOneByUsername($username); + if ($existingUsername) { + return $this->json(['status' => 'error', 'message' => 'Username Already Exists', 'timestamp' => $this->getTimestamp()], 400); } - if (!$this->validateUsername($userName)) { - return $this->json(['status' => 'error', 'message' => 'Invalid User Name', 'timestamp' => $this->getTimestamp()], 400); - } - - $user = new User(); - $principal = new Principal(); - - $user->setUsername($userName); - - $hash = password_hash($userPassword, PASSWORD_DEFAULT); - $user->setPassword($hash); + $hashed_password = password_hash($password, PASSWORD_DEFAULT); + $user = $utils->createUserWithDefaultObjects($username, $display_name, $email, $hashed_password, (true === $isAdmin || 'true' === $isAdmin) ? true : false); $entityManager = $doctrine->getManager(); - - $principal->setUri($user->getPrincipalUri()); - - $calendarInstance = new CalendarInstance(); - $calendar = new Calendar(); - $calendarInstance->setPrincipalUri($user->getPrincipalUri()) - ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal - ->setDisplayName($trans->trans('default.calendar.title')) - ->setDescription($trans->trans('default.calendar.description', ['user' => $userDisplayName])) - ->setCalendar($calendar); - - // Enable delegation by default - $principalProxyRead = new Principal(); - $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) - ->setIsMain(false); - $entityManager->persist($principalProxyRead); - - $principalProxyWrite = new Principal(); - $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) - ->setIsMain(false); - $entityManager->persist($principalProxyWrite); - - $addressbook = new AddressBook(); - $addressbook->setPrincipalUri($user->getPrincipalUri()) - ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal - ->setDisplayName($trans->trans('default.addressbook.title')) - ->setDescription($trans->trans('default.addressbook.description', ['user' => $userDisplayName])); - $entityManager->persist($calendarInstance); - $entityManager->persist($addressbook); - $entityManager->persist($principal); - - $principal->setDisplayName($userDisplayName) - ->setEmail($userEmail) - ->setIsAdmin($userIsAdmin); - - $entityManager->persist($user); $entityManager->flush(); $response = [ @@ -253,19 +206,18 @@ public function createUser(Request $request, ManagerRegistry $doctrine, Translat 'timestamp' => $this->getTimestamp(), ]; - return $this->json($response, 200); + return $this->json($response, 201); } /** * Deletes a specific user. * - * @param Request $request The HTTP POST request - * @param int $userId The ID of the user to delete + * @param int $userId The ID of the user to delete * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/users/{userId}', name: 'user_delete', methods: ['DELETE'], requirements: ['userId' => '\d+'])] - public function deleteUser(Request $request, int $userId, ManagerRegistry $doctrine): JsonResponse + public function deleteUser(int $userId, ManagerRegistry $doctrine, Utils $utils): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { @@ -273,71 +225,14 @@ public function deleteUser(Request $request, int $userId, ManagerRegistry $doctr } try { - $entityManager = $doctrine->getManager(); - $entityManager->remove($user); - - $principal = $doctrine->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); - $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); - $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); - - $entityManager->remove($principal); - - if ($principalProxyRead) { - $entityManager->remove($principalProxyRead); - } - - if ($principalProxyWrite) { - $entityManager->remove($principalProxyWrite); - } - - $principalUri = $user->getPrincipalUri(); - - // Remove calendars and addressbooks - $calendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); - foreach ($calendars ?? [] as $instance) { - // We're only removing the calendar objects / changes / and calendar if the deleted user is an owner, - // which means that the underlying calendar instance should not have another principal as owner. - $hasDifferentOwner = $doctrine->getRepository(CalendarInstance::class)->hasDifferentOwner($instance->getCalendar()->getId(), $principalUri); - if (!$hasDifferentOwner) { - foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { - $entityManager->remove($object); - } - foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { - $entityManager->remove($change); - } - // We need to remove the shared versions of this calendar, too - foreach ($instance->getCalendar()->getInstances() ?? [] as $instances) { - $entityManager->remove($instances); - } - $entityManager->remove($instance->getCalendar()); - } - $entityManager->remove($instance); - } - $calendarsSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); - foreach ($calendarsSubscriptions ?? [] as $subscription) { - $entityManager->remove($subscription); - } - $schedulingObjects = $doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($principalUri); - foreach ($schedulingObjects ?? [] as $object) { - $entityManager->remove($object); - } - - $addressbooks = $doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); - foreach ($addressbooks ?? [] as $addressbook) { - foreach ($addressbook->getCards() ?? [] as $card) { - $entityManager->remove($card); - } - foreach ($addressbook->getChanges() ?? [] as $change) { - $entityManager->remove($change); - } - $entityManager->remove($addressbook); - } - - $entityManager->flush(); + $utils->deleteUser($user); } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Delete User', 'timestamp' => $this->getTimestamp()], 500); } + $entityManager = $doctrine->getManager(); + $entityManager->flush(); + return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } diff --git a/src/Services/Utils.php b/src/Services/Utils.php index 38aca4bd..90a52a1c 100644 --- a/src/Services/Utils.php +++ b/src/Services/Utils.php @@ -5,7 +5,9 @@ use App\Entity\AddressBook; use App\Entity\Calendar; use App\Entity\CalendarInstance; +use App\Entity\CalendarSubscription; use App\Entity\Principal; +use App\Entity\SchedulingObject; use App\Entity\User; use Doctrine\Persistence\ManagerRegistry; use Symfony\Contracts\Translation\TranslatorInterface; @@ -58,6 +60,18 @@ public static function isValidUsername(?string $username): bool } public function createPasswordlessUserWithDefaultObjects(string $username, string $displayName, string $email) + { + // Set the password to a random string (but hashed beforehand) + $password = substr(bin2hex(random_bytes(256)), 0, 48); + $hash = password_hash($password, PASSWORD_DEFAULT); + + return $this->createUserWithDefaultObjects($username, $displayName, $email, $hash, false); + } + + /** + * Return the new user, persisted in the database. + */ + public function createUserWithDefaultObjects(string $username, string $displayName, string $email, string $hashed_password, bool $isAdmin) { if (!self::isValidUsername($username)) { throw new \InvalidArgumentException(sprintf('Refusing to create the user "%s": a username may only contain letters, digits and the characters _ . @ + \' -', $username)); @@ -65,18 +79,29 @@ public function createPasswordlessUserWithDefaultObjects(string $username, strin $user = new User(); $user->setUsername($username); + $user->setPassword($hashed_password); - // Set the password to a random string (but hashed beforehand) - $randomBytes = substr(bin2hex(random_bytes(256)), 0, 48); - $hash = password_hash($randomBytes, PASSWORD_DEFAULT); - $user->setPassword($hash); + $this->createDefaultObjectsForUser($user, $displayName, $email, $isAdmin); + + $em = $this->doctrine->getManager(); + $em->persist($user); - // Create principal, default calendar and addressbook + return $user; + } + + /** + * Create the default objects for a new user: principal, default calendar + * and addressbook, all persisted in the database. + * + * Return the new principal + */ + public function createDefaultObjectsForUser(User $user, string $displayName, string $email, bool $isAdmin) + { $principal = new Principal(); $principal->setUri($user->getPrincipalUri()) ->setDisplayName($displayName) ->setEmail($email) - ->setIsAdmin(false); + ->setIsAdmin($isAdmin); $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); @@ -108,6 +133,74 @@ public function createPasswordlessUserWithDefaultObjects(string $username, strin $em->persist($calendarInstance); $em->persist($addressbook); $em->persist($principal); - $em->persist($user); + + return $principal; + } + + public function deleteUser(User $user) + { + $entityManager = $this->doctrine->getManager(); + $entityManager->remove($user); + + $principal = $this->doctrine->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + if (!$principal) { + throw new \Exception('Principal is null'); + } + + $principalProxyRead = $this->doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); + $principalProxyWrite = $this->doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); + + $entityManager->remove($principal); + + if ($principalProxyRead) { + $entityManager->remove($principalProxyRead); + } + + if ($principalProxyWrite) { + $entityManager->remove($principalProxyWrite); + } + + $principalUri = $user->getPrincipalUri(); + + // Remove calendars and addressbooks + $calendars = $this->doctrine->getRepository(CalendarInstance::class)->findByPrincipalUriWithCalendars($principalUri); + foreach ($calendars ?? [] as $instance) { + // We're only removing the calendar objects / changes / and calendar if the deleted user is an owner, + // which means that the underlying calendar instance should not have another principal as owner. + $hasDifferentOwner = $this->doctrine->getRepository(CalendarInstance::class)->hasDifferentOwner($instance->getCalendar()->getId(), $principalUri); + if (!$hasDifferentOwner) { + foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { + $entityManager->remove($object); + } + foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { + $entityManager->remove($change); + } + // We need to remove the shared versions of this calendar, too + foreach ($instance->getCalendar()->getInstances() ?? [] as $instances) { + $entityManager->remove($instances); + } + $entityManager->remove($instance->getCalendar()); + } + $entityManager->remove($instance); + } + $calendarsSubscriptions = $this->doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); + foreach ($calendarsSubscriptions ?? [] as $subscription) { + $entityManager->remove($subscription); + } + $schedulingObjects = $this->doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($principalUri); + foreach ($schedulingObjects ?? [] as $object) { + $entityManager->remove($object); + } + + $addressbooks = $this->doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); + foreach ($addressbooks ?? [] as $addressbook) { + foreach ($addressbook->getCards() ?? [] as $card) { + $entityManager->remove($card); + } + foreach ($addressbook->getChanges() ?? [] as $change) { + $entityManager->remove($change); + } + $entityManager->remove($addressbook); + } } } diff --git a/translations/messages+intl-icu.de.xlf b/translations/messages+intl-icu.de.xlf index 3f9a28c7..99e4b126 100644 --- a/translations/messages+intl-icu.de.xlf +++ b/translations/messages+intl-icu.de.xlf @@ -261,6 +261,10 @@ user.deleted Benutzer erfolgreich gelöscht + + user.deleted.error + Fehler beim Löschen dieses Benutzers + calendar.saved Kalender erfolgreich gespeichert @@ -585,7 +589,7 @@ calendar.share_access.3 lesen / schreiben - + calendar.public öffentlich diff --git a/translations/messages+intl-icu.en.xlf b/translations/messages+intl-icu.en.xlf index 4c6e1cd4..8c693210 100644 --- a/translations/messages+intl-icu.en.xlf +++ b/translations/messages+intl-icu.en.xlf @@ -261,6 +261,10 @@ user.deleted User deleted successfully + + user.deleted.error + Error while deleting this user + calendar.saved Calendar saved successfully @@ -585,7 +589,7 @@ calendar.share_access.3 read / write - + calendar.public public diff --git a/translations/messages+intl-icu.fr.xliff b/translations/messages+intl-icu.fr.xliff index fad1aede..753a4e6d 100644 --- a/translations/messages+intl-icu.fr.xliff +++ b/translations/messages+intl-icu.fr.xliff @@ -261,6 +261,10 @@ user.deleted Utilisateur supprimé avec succès + + user.deleted.error + Erreur lors de la suppression de cet utilisateur + calendar.saved Calendrier enregistré avec succès @@ -585,7 +589,7 @@ calendar.share_access.3 lecture / écriture - + calendar.share_access.10 public From c838089d3dce5115be4081b43744580945b72019 Mon Sep 17 00:00:00 2001 From: Oliver Bates Date: Wed, 23 Sep 2026 12:24:00 +0200 Subject: [PATCH 3/5] Added API docs for creating and deleting users --- docs/api/v1/users/create.md | 172 ++++++++++++++++++++++++++++++++++++ docs/api/v1/users/delete.md | 86 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 docs/api/v1/users/create.md create mode 100644 docs/api/v1/users/delete.md diff --git a/docs/api/v1/users/create.md b/docs/api/v1/users/create.md new file mode 100644 index 00000000..a5a43197 --- /dev/null +++ b/docs/api/v1/users/create.md @@ -0,0 +1,172 @@ +# Create User + +Create a new user. + +**URL** : `/api/v1/users/create` + +**Method** : `POST` + +**Auth required** : YES + +**Request Body constraints** + +```json +{ + "name": "[string: username, alphanumeric, spaces, underscores and hyphens, max 64 chars]", + "display_name": "[string: max 255 chars]", + "email": "[string: valid email, max 255 chars]", + "password": "[string: max 255 chars]", + "is_admin": "[string or boolean: 'true', 'false', true, false, default false]" +} +``` + +**Body example** + +```json +{ + "name": "user", + "display_name": "New User", + "email": "user@user.user", + "password": "password", + "is_admin": "false" +} +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "data": { + "user_id": 5, + "user_name": "user" + }, + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If request body contains invalid JSON. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid JSON", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Userame", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If 'display_name' parameter is invalid (null or empty string). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Display Name", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If 'email' parameter is invalid (null, empty string or invalid email). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Email", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If 'password' parameter is invalid (null or empty string). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Password", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If 'is_admin' parameter is invalid (not in [true, false, 'true', 'false']). + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Invalid Is Admin", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If user with specified username already exists. + +**Code** : `400 BAD REQUEST` + +**Content** : + +```json +{ + "status": "error", + "message": "Usrname Already Exists", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` diff --git a/docs/api/v1/users/delete.md b/docs/api/v1/users/delete.md new file mode 100644 index 00000000..28500151 --- /dev/null +++ b/docs/api/v1/users/delete.md @@ -0,0 +1,86 @@ +# Delete User + +Deletes a specific user. + +**URL** : `/api/v1/users/:user_id` + +**Method** : `DELETE` + +**Auth required** : YES + +**Params constraints** + +``` +:user_id -> "[user id as an int]", +``` + +**URL example** + +``` +/api/v1/users/1 +``` + +## Success Response + +**Code** : `200 OK` + +**Content examples** + +```json +{ + "status": "success", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +## Error Response + +**Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. + +**Code** : `401 UNAUTHORIZED` + +**Content** : + +```json +{ + "message": "No API token provided", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +or + +```json +{ + "message": "Invalid API token", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If user is not found. + +**Code** : `404 NOT FOUND` + +**Content** : + +```json +{ + "status": "error", + "message": "User Not Found", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` + +**Condition** : If any error arises while deleting, like if a principal was deleted without removing the corresponding user. + +**Code** : `500 INTERNAL SERVER ERROR` + +**Content** : + +```json +{ + "status": "error", + "message": "Failed to Delete User", + "timestamp": "2026-09-23T15:01:33+01:00" +} +``` From d03237dcd486277af16a4121aafd395b9a6aa59e Mon Sep 17 00:00:00 2001 From: Oliver Bates Date: Wed, 23 Sep 2026 14:01:09 +0200 Subject: [PATCH 4/5] Small typo --- src/Controller/Api/ApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index c8c8ee7a..68905c43 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -166,7 +166,7 @@ public function createUser(Request $request, ManagerRegistry $doctrine, Translat } $username = $data['name'] ?? null; - if (is_null($username) || !$this->validateUsername($userName)) { + if (is_null($username) || !$this->validateUsername($username)) { return $this->json(['status' => 'error', 'message' => 'Invalid Username', 'timestamp' => $this->getTimestamp()], 400); } $display_name = $data['display_name'] ?? null; From 870ff88583743d16d676b66e8d967856a6cb124a Mon Sep 17 00:00:00 2001 From: Oliver Bates Date: Wed, 23 Sep 2026 15:05:17 +0200 Subject: [PATCH 5/5] Added Functional API tests for user creation and deletion --- src/Controller/Api/ApiController.php | 2 +- .../Controllers/ApiControllerTest.php | 285 ++++++++++++++++++ 2 files changed, 286 insertions(+), 1 deletion(-) diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 68905c43..5e84d117 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -227,7 +227,7 @@ public function deleteUser(int $userId, ManagerRegistry $doctrine, Utils $utils) try { $utils->deleteUser($user); } catch (\Exception $e) { - return $this->json(['status' => 'error', 'message' => 'Failed to Delete User', 'timestamp' => $this->getTimestamp()], 500); + return $this->json(['status' => 'error', 'message' => 'Error while Deleting User', 'timestamp' => $this->getTimestamp()], 500); } $entityManager = $doctrine->getManager(); diff --git a/tests/Functional/Controllers/ApiControllerTest.php b/tests/Functional/Controllers/ApiControllerTest.php index dc559646..03161834 100644 --- a/tests/Functional/Controllers/ApiControllerTest.php +++ b/tests/Functional/Controllers/ApiControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Functional; +use App\Entity\User; use App\Entity\Calendar; use App\Entity\CalendarInstance; use App\Entity\CalendarObject; @@ -210,6 +211,290 @@ public function testUserDetails(): void $this->assertStringEqualsStringIgnoringLineEndings($username, $data['data']['username']); } + /* + * Test the user creation endpoint + */ + public function testUserCreate(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Create user API request with JSON body + $payload = [ + 'name' => 'user', + 'display_name' => 'user display name', + 'email' => 'user@email.com', + 'password' => 'password', + 'is_admin' => false, + ]; + + $client->request('POST', '/api/v1/users/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + + // Check if the user was created + $user = $em->getRepository(User::class)->findOneByUsername('user'); + $this->assertNotNull($user, 'The user was not created'); + + // Check if user details are correct + $this->assertArrayHasKey('user_id', $data['data']); + $this->assertEquals($user->getId(), $data['data']['user_id']); + $this->assertArrayHasKey('user_name', $data['data']); + $this->assertEquals($user->getUsername(), $data['data']['user_name']); + $this->assertTrue(password_verify('password', $user->getPassword())); + + // Check if the principal was created + $principal = $em->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + $this->assertNotNull($principal, 'The principal was not created'); + + // Check if principal details are correct + $this->assertEquals('user display name', $principal->getDisplayName()); + $this->assertEquals('user@email.com', $principal->getEmail()); + $this->assertFalse($principal->getIsAdmin()); + } + + /* + * Test the user creation endpoint if is_admin is passed as string + */ + public function testUserCreateIfIsAdminIsString(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Create user API request with JSON body + $payload = [ + 'name' => 'user', + 'display_name' => 'user display name', + 'email' => 'user@email.com', + 'password' => 'password', + 'is_admin' => "true", + ]; + + $client->request('POST', '/api/v1/users/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + + // Check if the user was created + $user = $em->getRepository(User::class)->findOneByUsername('user'); + $this->assertNotNull($user, 'The user was not created'); + + // Check if user details are correct + $this->assertArrayHasKey('user_id', $data['data']); + $this->assertEquals($user->getId(), $data['data']['user_id']); + $this->assertArrayHasKey('user_name', $data['data']); + $this->assertEquals($user->getUsername(), $data['data']['user_name']); + $this->assertTrue(password_verify('password', $user->getPassword())); + + // Check if the principal was created + $principal = $em->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + $this->assertNotNull($principal, 'The principal was not created'); + + // Check if principal details are correct + $this->assertEquals('user display name', $principal->getDisplayName()); + $this->assertEquals('user@email.com', $principal->getEmail()); + $this->assertTrue($principal->getIsAdmin()); + } + + /* + * Test that the user creation endpoint fails when passing invalid email + */ + public function testUserCreateFailInvalidEmail(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Create user API request with JSON body + $payload = [ + 'name' => 'user', + 'display_name' => 'user display name', + 'email' => 'email', + 'password' => 'password', + 'is_admin' => false, + ]; + + $client->request('POST', '/api/v1/users/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('Invalid Email', $data['message']); + } + + /* + * Test that the user creation endpoint fails when passing invalid is_admin + */ + public function testUserCreateFailInvalidIsAdmin(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Create user API request with JSON body + $payload = [ + 'name' => 'user', + 'display_name' => 'user display name', + 'email' => 'user@email.com', + 'password' => 'password', + 'is_admin' => "notfalse", + ]; + + $client->request('POST', '/api/v1/users/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('Invalid Is Admin', $data['message']); + } + + /* + * Test that the user creation endpoint fails when the user already exists + */ + public function testUserCreateFailWhenUserAlreadyExists(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Get username from existing user lists + $username = $this->getUserUsername($client, 0); + + // Create user API request with JSON body + $payload = [ + 'name' => $username, + 'display_name' => 'user display name', + 'email' => 'user@email.com', + 'password' => 'password', + 'is_admin' => false, + ]; + + $client->request('POST', '/api/v1/users/create', [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ], json_encode($payload)); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('Username Already Exists', $data['message']); + } + + /* + * Test the user deletion endpoint + */ + public function testUserDelete(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Get userId, username and principal from existing user + $userId = $this->getUserId($client, 0); + $username = $this->getUserUsername($client, 0); + $user = $em->getRepository(User::class)->findOneByUsername($username); + $principal = $em->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + + $client->request('DELETE', '/api/v1/users/'.$userId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + + // Check if the user was deleted + $user = $em->getRepository(User::class)->findOneByUsername($username); + $this->assertNull($user, 'The user was not deleted'); + + // Check if the principal was deleted + $principal = $em->getRepository(Principal::class)->findOneByUri($principal->getUri()); + $this->assertNull($principal, 'The principal was not deleted'); + } + + /* + * Test that the user deletion endpoint fails when the user does not exist + */ + public function testUserDeleteFailUserDoesNotExists(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Get userId, username and principal from existing user + $userId = $this->getUserId($client, 0); + $username = $this->getUserUsername($client, 0); + $user = $em->getRepository(User::class)->findOneByUsername($username); + $em->remove($user); + $em->flush(); + + $client->request('DELETE', '/api/v1/users/'.$userId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('User Not Found', $data['message']); + } + + /* + * Test that the user deletion endpoint fails when the user does not have a principal + */ + public function testUserDeleteFailIfNoPrincipal(): void + { + $client = static::createClient(); + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + + // Get userId, username and principal from existing user + $userId = $this->getUserId($client, 0); + $username = $this->getUserUsername($client, 0); + $user = $em->getRepository(User::class)->findOneByUsername($username); + $principal = $em->getRepository(Principal::class)->findOneByUri($user->getPrincipalUri()); + $em->remove($principal); + $em->flush(); + + $client->request('DELETE', '/api/v1/users/'.$userId, [], [], [ + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], + 'CONTENT_TYPE' => 'application/json', + ]); + + $this->assertResponseStatusCodeSame(500); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('error', $data['status']); + $this->assertStringContainsString('Error while Deleting User', $data['message']); + } + /* * Test the user calendars list endpoint */