Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Claim personal invitations #4953

Draft
wants to merge 2 commits into
base: devel
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/src/Repository/CampCollaborationRepository.php
Original file line number Diff line number Diff line change
@@ -34,6 +34,13 @@ public function findByUserAndIdAndInvited(User $user, string $id): ?CampCollabor
return $this->findOneBy(['user' => $user, 'id' => $id, 'status' => CampCollaboration::STATUS_INVITED]);
}

/**
* @return CampCollaboration[]
*/
public function findAllByInviteEmailAndInvited(string $inviteEmail): array {
return $this->findBy(['inviteEmail' =>$inviteEmail, 'status' => CampCollaboration::STATUS_INVITED]);
}

/**
* @return CampCollaboration[]
*/
42 changes: 41 additions & 1 deletion api/src/State/ProfileUpdateProcessor.php
Original file line number Diff line number Diff line change
@@ -5,9 +5,17 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Profile;
use App\Entity\User;
use App\Repository\CampCollaborationRepository;
use App\Repository\UserRepository;
use App\Service\MailService;
use App\State\Util\AbstractPersistProcessor;
use App\Util\IdGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
@@ -19,7 +27,11 @@ class ProfileUpdateProcessor extends AbstractPersistProcessor {
public function __construct(
ProcessorInterface $decorated,
private PasswordHasherFactoryInterface $pwHasherFactory,
private MailService $mailService
private MailService $mailService,
private readonly Security $security,
private readonly CampCollaborationRepository $campCollaborationRepository,
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $em,
) {
parent::__construct($decorated);
}
@@ -58,9 +70,37 @@ public function onAfter($data, Operation $operation, array $uriVariables = [], a
$this->mailService->sendEmailVerificationMail($data->user, $data);
$data->untrustedEmailKey = null;
}

$user = $this->getUser();
$personalInvitationsForNewEmail = $this->campCollaborationRepository->findAllByInviteEmailAndInvited($data->email);
foreach ($personalInvitationsForNewEmail as $invitation) {
// Convert all invitations who specifically invited this email address to
// personal invitations, which the invited user will be able to see and
// accept / reject in the UI, even without receiving the invitation email.
// This is done by setting the user field instead of the inviteEmail field.
$invitation->inviteEmail = null;
$invitation->user = $user;
$this->em->persist($invitation);
}
$this->em->flush();
}

private function getResetKeyHasher(): PasswordHasherInterface {
return $this->pwHasherFactory->getPasswordHasher('EmailVerification');
}

/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
private function getUser(): ?User {
$user = $this->security->getUser();
if (null == $user) {
// This should never happen because it should be caught earlier by our security settings
// on all API operations using this processor.
throw new AccessDeniedHttpException();
}

return $this->userRepository->loadUserByIdentifier($user->getUserIdentifier());
}
}
22 changes: 21 additions & 1 deletion api/src/State/UserActivateProcessor.php
Original file line number Diff line number Diff line change
@@ -5,15 +5,19 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Repository\CampCollaborationRepository;
use App\State\Util\AbstractPersistProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
* @template-extends AbstractPersistProcessor<User>
*/
class UserActivateProcessor extends AbstractPersistProcessor {
public function __construct(
ProcessorInterface $decorated
ProcessorInterface $decorated,
private readonly CampCollaborationRepository $campCollaborationRepository,
private readonly EntityManagerInterface $em,
) {
parent::__construct($decorated);
}
@@ -32,4 +36,20 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],

return $data;
}

public function onAfter($data, Operation $operation, array $uriVariables = [], array $context = []): void {
/** @var User $user */
$user = $data;
$personalInvitationsForNewEmail = $this->campCollaborationRepository->findAllByInviteEmailAndInvited($user->getProfile()->email);
foreach ($personalInvitationsForNewEmail as $invitation) {
// Convert all invitations who specifically invited this email address to
// personal invitations, which the invited user will be able to see and
// accept / reject in the UI, even without receiving the invitation email.
// This is done by setting the user field instead of the inviteEmail field.
$invitation->inviteEmail = null;
$invitation->user = $user;
$this->em->persist($invitation);
}
$this->em->flush();
}
}
40 changes: 40 additions & 0 deletions api/tests/Api/PersonalInvitations/ListPersonalInvitationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Tests\Api\PersonalInvitations;

use App\Entity\User;
use App\Tests\Api\ECampApiTestCase;

/**
* @internal
*/
class ListPersonalInvitationsTest extends ECampApiTestCase {
public function testListPersonalInvitationsIsDeniedForAnonymousUser() {
static::createBasicClient()->request('GET', '/personal_invitations');
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains([
'code' => 401,
'message' => 'JWT Token not found',
]);
}

public function testListPersonalInvitationsIsAllowedForLoggedInUserButFiltered() {
/** @var User $user */
$user = static::getFixture('user6invited');
$client = static::createClientWithCredentials(['email' => $user->getProfile()->email]);
$client->request('GET', '/personal_invitations');
$this->assertResponseStatusCodeSame(200);
$invitation = static::getFixture('campCollaboration6invitedWithUser');
$this->assertJsonContains([
'totalItems' => 1,
'_links' => [
'items' => [
['href' => "/personal_invitations/{$invitation->getId()}"]
],
],
'_embedded' => [
'items' => [],
],
]);
}
}
70 changes: 70 additions & 0 deletions api/tests/Api/Profiles/UpdateProfileTest.php
Original file line number Diff line number Diff line change
@@ -2,9 +2,15 @@

namespace App\Tests\Api\Profiles;

use _PHPStan_5473b6701\Nette\Neon\Exception;
use App\Entity\Camp;
use App\Entity\CampCollaboration;
use App\Entity\Profile;
use App\Tests\Api\ECampApiTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;

/**
* @internal
@@ -91,6 +97,70 @@ public function testPatchProfileDisallowsChangingEmail() {
]);
}

public function testPatchProfileCollectsPersonalInvitation() {

$client = static::createClientWithCredentials();
// Disable resetting the database between the two requests
$client->disableReboot();

$camp = $this->getEntityManager()->find(Camp::class, static::getFixture('campUnrelated')->getId());
$camp2 = $this->getEntityManager()->find(Camp::class, static::getFixture('campPrototype')->getId());

// create an invitation which will be claimed by the user
$invitation1 = new CampCollaboration();
$invitation1->camp = $camp;
$invitation1->status = CampCollaboration::STATUS_INVITED;
$invitation1->inviteEmail = '[email protected]';
$invitation1->inviteKeyHash = '1234123412341234';
$invitation1->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation1);

// create a rejected invitation which will not be claimed by the user
$invitation2 = new CampCollaboration();
$invitation2->camp = $camp2;
$invitation2->status = CampCollaboration::STATUS_INACTIVE;
$invitation2->inviteEmail = '[email protected]';
$invitation2->inviteKeyHash = '2341234123412341';
$invitation2->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation2);

// create an unrelated invitation which will not be claimed by the user
$invitation3 = new CampCollaboration();
$invitation3->camp = $camp;
$invitation3->status = CampCollaboration::STATUS_INVITED;
$invitation3->inviteEmail = '[email protected]';
$invitation3->inviteKeyHash = '3412341234123412';
$invitation3->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation3);

$this->getEntityManager()->flush();

/** @var Profile $profile */
$profile = static::getFixture('profile1manager');

// when
$client->request('PATCH', '/profiles/'.$profile->getId(), ['json' => [
'nickname' => 'Linux',
], 'headers' => ['Content-Type' => 'application/merge-patch+json']]);
$this->assertResponseStatusCodeSame(200);

// then
$client->request('GET', '/personal_invitations');

// User has one personal invitation waiting for them
$this->assertJsonContains([
'totalItems' => 1,
'_links' => [
'items' => [
['href' => "/personal_invitations/{$invitation1->getId()}"]
],
],
'_embedded' => [
'items' => [],
],
]);
}

public function testPatchProfileTrimsFirstname() {
$profile = static::getFixture('profile1manager');
static::createClientWithCredentials()->request('PATCH', '/profiles/'.$profile->getId(), ['json' => [
77 changes: 77 additions & 0 deletions api/tests/Api/Users/CreateUserTest.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Entity\Camp;
use App\Entity\CampCollaboration;
use App\Entity\Profile;
use App\Entity\User;
use App\Tests\Api\ECampApiTestCase;
@@ -77,6 +79,81 @@ public function testLoginAfterRegistrationAndActivation() {
$this->assertResponseIsSuccessful();
}

public function testActivationClaimsOpenInvitations() {
// given
$client = static::createBasicClient();
// Disable resetting the database between the two requests
$client->disableReboot();

$camp = $this->getEntityManager()->find(Camp::class, static::getFixture('camp1')->getId());
$camp2 = $this->getEntityManager()->find(Camp::class, static::getFixture('camp2')->getId());

// create an invitation which will be claimed by the user
$invitation1 = new CampCollaboration();
$invitation1->camp = $camp;
$invitation1->status = CampCollaboration::STATUS_INVITED;
$invitation1->inviteEmail = '[email protected]';
$invitation1->inviteKeyHash = '1234123412341234';
$invitation1->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation1);

// create a rejected invitation which will not be claimed by the user
$invitation2 = new CampCollaboration();
$invitation2->camp = $camp2;
$invitation2->status = CampCollaboration::STATUS_INACTIVE;
$invitation2->inviteEmail = '[email protected]';
$invitation2->inviteKeyHash = '2341234123412341';
$invitation2->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation2);

// create an unrelated invitation which will not be claimed by the user
$invitation3 = new CampCollaboration();
$invitation3->camp = $camp;
$invitation3->status = CampCollaboration::STATUS_INVITED;
$invitation3->inviteEmail = '[email protected]';
$invitation3->inviteKeyHash = '3412341234123412';
$invitation3->role = CampCollaboration::ROLE_MANAGER;
$this->getEntityManager()->persist($invitation3);

$this->getEntityManager()->flush();

// register user
$result = $client->request('POST', '/users', ['json' => $this->getExampleWritePayload()]);
$this->assertResponseStatusCodeSame(201);

$userId = $result->toArray()['id'];
$user = $this->getEntityManager()->getRepository(User::class)->find($userId);

// when
// activate user
$client->request('PATCH', "/users/{$userId}/activate", ['json' => [
'activationKey' => $user->activationKey,
], 'headers' => ['Content-Type' => 'application/merge-patch+json']]);
$this->assertResponseIsSuccessful();

// login
$client->request('POST', '/authentication_token', ['json' => [
'identifier' => '[email protected]',
'password' => 'learning-by-doing-101',
]]);

// then
$client->request('GET', '/personal_invitations');

// User has one personal invitation waiting for them
$this->assertJsonContains([
'totalItems' => 1,
'_links' => [
'items' => [
['href' => "/personal_invitations/{$invitation1->getId()}"]
],
],
'_embedded' => [
'items' => [],
],
]);
}

public function testActivationFailsIfAlreadyActivated() {
$client = static::createBasicClient();
// Disable resetting the database between the two requests