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
Show file tree
Hide file tree
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
Expand Up @@ -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[]
*/
Expand Down
42 changes: 41 additions & 1 deletion api/src/State/ProfileUpdateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Up @@ -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);
}
Expand All @@ -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
Expand Up @@ -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
Expand Down Expand Up @@ -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 = 'test@example.com';
$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 = 'test@example.com';
$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 = 'someone-else@example.com';
$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' => [
Expand Down
77 changes: 77 additions & 0 deletions api/tests/Api/Users/CreateUserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = 'bi-pi@example.com';
$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 = 'bi-pi@example.com';
$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 = 'someone-else@example.com';
$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' => 'bi-pi@example.com',
'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
Expand Down
Loading