em = self::getContainer()->get(EntityManagerInterface::class); $this->em->getConnection()->beginTransaction(); $this->auditRecords = new TestHandler(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } public function testAnEmployeeIsLoggedInAndCreatedLocally(): void { $user = $this->authenticate([ 'email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE'], 'profile' => ['first_name' => 'Anna', 'last_name' => 'Berger'], ]); self::assertSame('anna@ep-reisen.de', $user->getEmail()); self::assertSame('Anna Berger', $user->getName()); self::assertContains(Role::EMPLOYEE, $user->getRoles()); } public function testAnAdministratorIsLoggedInToo(): void { $user = $this->authenticate(['email' => 'chef@ep-reisen.de', 'roles' => ['ROLE_ADMIN']]); self::assertContains(Role::ADMIN, $user->getRoles()); } public function testSomebodyWithNeitherRoleIsRefused(): void { $this->expectException(CustomUserMessageAuthenticationException::class); $this->expectExceptionMessage('Mitarbeiter- oder Administrator-Rolle'); $this->authenticate(['email' => 'kunde@example.org', 'roles' => ['ROLE_CUSTOMER', 'ROLE_TEAMER']]); } public function testARefusedLoginCreatesNoLocalAccount(): void { try { $this->authenticate(['email' => 'kunde@example.org', 'roles' => ['ROLE_CUSTOMER']]); } catch (CustomUserMessageAuthenticationException) { } self::assertNull($this->em->getRepository(User::class)->findOneBy(['email' => 'kunde@example.org'])); } /** * The roles claim is the identity provider's word, and myep is not the only thing that could * ever put a value in it. Only what this application recognises is stored. */ public function testOnlyWhitelistedRolesAreStored(): void { $user = $this->authenticate([ 'email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE', 'ROLE_SUPER_ADMIN', 'ROLE_HOUSE_MANAGER'], ]); self::assertSame([Role::EMPLOYEE, 'ROLE_USER'], $user->getRoles()); } public function testALoginWithoutAnEmailClaimIsRefused(): void { $this->expectException(CustomUserMessageAuthenticationException::class); $this->authenticate(['roles' => ['ROLE_EMPLOYEE']]); } /** * The consumer guide is explicit: profile.communication.email comes from the CRM address * record and can belong to a different contact entirely. Keying on it would log somebody in * as their colleague. */ public function testIdentityComesFromTheTopLevelEmailNotTheProfileOne(): void { $user = $this->authenticate([ 'email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE'], 'profile' => [ 'first_name' => 'Anna', 'last_name' => 'Berger', 'communication' => ['email' => 'zentrale@ep-reisen.de'], ], ]); self::assertSame('anna@ep-reisen.de', $user->getEmail()); } public function testAnExistingAccountIsUpdatedRatherThanDuplicated(): void { $existing = new User('anna@ep-reisen.de', 'Anna Alt'); $existing->setRoles([Role::EMPLOYEE]); $this->em->persist($existing); $this->em->flush(); $user = $this->authenticate([ 'email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE'], 'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'], ]); self::assertSame($existing->getId(), $user->getId()); self::assertSame('Anna Neu', $user->getName()); self::assertCount(1, $this->em->getRepository(User::class)->findBy(['email' => 'anna@ep-reisen.de'])); } /** * myep leads: a role it stops reporting is withdrawn here, not kept because it was granted * once. */ public function testARoleMyepNoLongerReportsIsWithdrawn(): void { $existing = new User('chef@ep-reisen.de', 'Chef'); $existing->setRoles([Role::EMPLOYEE, Role::ADMIN]); $this->em->persist($existing); $this->em->flush(); $user = $this->authenticate(['email' => 'chef@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); self::assertNotContains(Role::ADMIN, $user->getRoles()); } public function testTheEmailStandsInForAMissingProfileName(): void { // The profile scope may simply not have been granted. $user = $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); self::assertSame('anna@ep-reisen.de', $user->getName()); } public function testAMalformedRolesClaimIsTreatedAsNoRoles(): void { $this->expectException(CustomUserMessageAuthenticationException::class); $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => 'ROLE_EMPLOYEE']); } public function testAFailedTokenExchangeBecomesAFriendlyFailure(): void { $this->expectException(CustomUserMessageAuthenticationException::class); $this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.'); $this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch')) ->authenticate(new Request()); } public function testAnUnreachableUserinfoBecomesAFriendlyFailure(): void { $this->expectException(CustomUserMessageAuthenticationException::class); $this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.'); $this->authenticator(throwOnUserinfo: new UserinfoUnavailableException('boom')) ->authenticate(new Request()); } public function testAFirstSignInIsAudited(): void { $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); $record = $this->audited('User account provisioned.'); self::assertSame('anna@ep-reisen.de', $record->context['account']); self::assertContains(Role::EMPLOYEE, $record->context['roles']); } /** * Roles follow myep on every sign-in, so somebody can lose administrative access without * anybody here doing anything -- which is exactly the kind of change the trail is for. */ public function testAWithdrawnRoleIsAudited(): void { $existing = new User('chef@ep-reisen.de', 'Chef'); $existing->setRoles([Role::EMPLOYEE, Role::ADMIN]); $this->em->persist($existing); $this->em->flush(); $this->authenticate(['email' => 'chef@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); $record = $this->audited('User roles changed.'); self::assertSame([Role::EMPLOYEE, Role::ADMIN], $record->context['previous_roles']); self::assertSame([Role::EMPLOYEE], $record->context['roles']); } public function testASignInThatChangesNothingIsNotAuditedAsARoleChange(): void { $existing = new User('anna@ep-reisen.de', 'Anna'); $existing->setRoles([Role::EMPLOYEE]); $this->em->persist($existing); $this->em->flush(); $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); self::assertFalse($this->auditRecords->hasInfoThatContains('User roles changed.')); self::assertFalse($this->auditRecords->hasInfoThatContains('User account provisioned.')); } /** * The reason is the point: by the time the failure reaches the login page it is one German * sentence, whatever went wrong. */ public function testARefusedSignInIsAuditedWithWhyItWasRefused(): void { try { $this->authenticate(['email' => 'kunde@example.org', 'roles' => ['ROLE_CUSTOMER']]); } catch (CustomUserMessageAuthenticationException) { } $record = $this->audited('Sign-in refused.'); self::assertSame('no_eligible_role', $record->context['reason']); self::assertSame('kunde@example.org', $record->context['account']); } public function testAFailureBeforeTheUserIsKnownIsAuditedWithoutAnAccount(): void { try { $this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch')) ->authenticate(new Request()); } catch (CustomUserMessageAuthenticationException) { } $record = $this->audited('Sign-in refused.'); self::assertSame('no_access_token', $record->context['reason']); self::assertNull($record->context['account']); } public function testItOnlyFiresOnTheCallbackRoute(): void { $authenticator = $this->authenticator(); $callback = new Request(); $callback->attributes->set('_route', 'app_auth_check'); self::assertTrue($authenticator->supports($callback)); $dashboard = new Request(); $dashboard->attributes->set('_route', 'app_dashboard'); self::assertFalse($authenticator->supports($dashboard)); } /** * @param array $userinfo */ private function authenticate(array $userinfo): User { $passport = $this->authenticator($userinfo)->authenticate(new Request()); $badge = $passport->getBadge(UserBadge::class); self::assertInstanceOf(UserBadge::class, $badge); $user = $badge->getUser(); self::assertInstanceOf(User::class, $user); return $user; } /** * @param array $userinfo */ private function authenticator( array $userinfo = [], ?\Throwable $throwOnToken = null, ?\Throwable $throwOnUserinfo = null, ): EpAuthenticator { $client = $this->createStub(EpClient::class); if (null !== $throwOnToken) { $client->method('fetchAccessToken')->willThrowException($throwOnToken); } else { $client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token'])); } if (null !== $throwOnUserinfo) { $client->method('fetchUserinfo')->willThrowException($throwOnUserinfo); } else { $client->method('fetchUserinfo')->willReturn($userinfo); } return new EpAuthenticator( $client, self::getContainer()->get(UserRepository::class), $this->em, self::getContainer()->get(UrlGeneratorInterface::class), new NullLogger(), new AuditLogger(new Logger('audit', [$this->auditRecords])), ); } /** The one audit record carrying this message. */ private function audited(string $message): LogRecord { $records = array_values(array_filter( $this->auditRecords->getRecords(), static fn (LogRecord $record): bool => $message === $record->message, )); self::assertCount(1, $records, \sprintf('Expected exactly one "%s" audit record.', $message)); return $records[0]; } }