diff --git a/Provider.php b/Provider.php index 5ea8604..5b08994 100644 --- a/Provider.php +++ b/Provider.php @@ -1245,10 +1245,7 @@ function doRegister() { $cpf = str_replace(".","",$cpf); // remove "." // generate the token hash - $source = rand(3333, 8888); - $cut = rand(10, 30); - $string = $this->hashPassword($source); - $token = substr($string, $cut, 20); + $token = $this->generateAccountValidationToken(); // Oauth pattern $response = [ @@ -1273,8 +1270,6 @@ function doRegister() { $user = $this->_createUser($response); $app->applyHookBoundTo($this, 'auth.createUser:after', [$user, $response]); - $baseUrl = $app->getBaseUrl(); - if(!$user) { $error['user']['createUser'] = i::__('Não foi possível criar o usuário. Entre em contato com suporte', 'multipleLocal'); @@ -1284,34 +1279,7 @@ function doRegister() { ]; } - //ATENÇÃO !! Se for necessario "padronizar" os emails com header/footers, é necessario adapatar o 'mustache', e criar uma mini estrutura de pasta de emails em 'MultipleLocalAuth\views' - $mustache = new \Mustache_Engine(); - $site_name = $app->siteName; - $content = $mustache->render( - file_get_contents( - __DIR__. - DIRECTORY_SEPARATOR.'views'. - DIRECTORY_SEPARATOR.'auth'. - DIRECTORY_SEPARATOR.'email-to-validate-account.html' - ), array( - "siteName" => $site_name, - "user" => $user->profile->name, - "urlToValidateAccount" => $baseUrl.'auth/confirma-email?token='.$token, - "baseUrl" => $baseUrl, - "urlSupportChat" => $this->_config['urlSupportChat'], - "urlSupportEmail" => $this->_config['urlSupportEmail'], - "urlSupportSite" => $this->_config['urlSupportSite'], - "textSupportSite" => $this->_config['textSupportSite'], - "urlImageToUseInEmails" => $this->getImageImageURl(), - ) - ); - - $app->createAndSendMailMessage([ - 'from' => $app->config['mailer.from'], - 'to' => $user->email, - 'subject' => "Bem-vindo ao ".$site_name, - 'body' => $content - ]); + $this->sendAccountValidationEmail($user, $token); $app->disableAccessControl(); $user->{self::$passMetaName} = $app->auth->hashPassword($pass); @@ -1356,8 +1324,116 @@ function getImageImageURl () { return $app->view->asset('img/mail-image.png', false); } } - - + + /** + * Gera o token usado no link de validação de conta + * + * @return string + */ + protected function generateAccountValidationToken() { + $source = rand(3333, 8888); + $cut = rand(10, 30); + $string = $this->hashPassword($source); + + return substr($string, $cut, 20); + } + + /** + * Monta e envia ao usuário o e-mail com o link de validação de conta + * + * @param Entities\User $user usuário destinatário + * @param string $token token de validação + * @return bool true se o e-mail foi enviado + */ + protected function sendAccountValidationEmail(Entities\User $user, $token) { + $app = App::i(); + + $baseUrl = $app->getBaseUrl(); + $site_name = $app->siteName; + + //ATENÇÃO !! Se for necessario "padronizar" os emails com header/footers, é necessario adapatar o 'mustache', e criar uma mini estrutura de pasta de emails em 'MultipleLocalAuth\views' + $mustache = new \Mustache_Engine(); + $content = $mustache->render( + file_get_contents( + __DIR__. + DIRECTORY_SEPARATOR.'views'. + DIRECTORY_SEPARATOR.'auth'. + DIRECTORY_SEPARATOR.'email-to-validate-account.html' + ), array( + "siteName" => $site_name, + "user" => $user->profile->name, + "urlToValidateAccount" => $baseUrl.'auth/confirma-email?token='.$token, + "baseUrl" => $baseUrl, + "urlSupportChat" => $this->_config['urlSupportChat'], + "urlSupportEmail" => $this->_config['urlSupportEmail'], + "urlSupportSite" => $this->_config['urlSupportSite'], + "textSupportSite" => $this->_config['textSupportSite'], + "urlImageToUseInEmails" => $this->getImageImageURl(), + ) + ); + + return (bool) $app->createAndSendMailMessage([ + 'from' => $app->config['mailer.from'], + 'to' => $user->email, + 'subject' => "Bem-vindo ao ".$site_name, + 'body' => $content + ]); + } + + /** + * Este provedor valida a conta por e-mail quando a confirmação é exigida + * + * @return bool + */ + function supportsAccountValidation() { + return (bool) ($this->_config['userMustConfirmEmailToUseTheSystem'] ?? false); + } + + /** + * A conta é considerada validada exceto quando o metadado accountIsActive é '0' + * + * A comparação reproduz a regra aplicada no login (veja doLogin): usuários sem o + * metadado, anteriores à validação por e-mail, não são bloqueados e portanto não + * estão pendentes de validação. + * + * @param Entities\User $user + * @return bool + */ + function isAccountValidated(Entities\User $user) { + return $user->getMetadata(self::$accountIsActiveMetadata) !== '0'; + } + + /** + * Reenvia ao usuário o e-mail com o link de validação de conta + * + * Reaproveita o token existente para não invalidar links já enviados e gera um + * novo apenas quando o usuário ainda não tem token. + * + * @param Entities\User $user + * @return bool true se o e-mail foi enviado + */ + function resendAccountValidationEmail(Entities\User $user) { + $app = App::i(); + + if (!$user->email) { + return false; + } + + $token = $user->getMetadata(self::$tokenVerifyAccountMetadata); + + if (!$token) { + $token = $this->generateAccountValidationToken(); + + $app->disableAccessControl(); + $user->setMetadata(self::$tokenVerifyAccountMetadata, $token); + $user->saveMetadata(true); + $app->enableAccessControl(); + } + + return $this->sendAccountValidationEmail($user, $token); + } + + /********************************************************************************/ /***************************** OPAUTH METHODS **********************************/ /********************************************************************************/ diff --git a/README.md b/README.md index afea651..dd30d47 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,40 @@ Você pode adicionar ou remover estratégias conforme necessário; qualquer estr - `GET|POST auth.govbr-email`: coleta e-mail alternativo quando o e-mail do Gov.br já está em uso (criação de conta). ## Testes -Regras de conta Gov.br (CPF / e-mail único) têm testes unitários em `tests/`: + +### Suíte integrada + +Testes que exercitam o `Provider` precisam do core carregado, e por isso rodam na stack de testes do repositório principal com um override que define este plugin como provedor de autenticação. Os comandos abaixo são executados a partir de `tests/` do repositório principal. + +```bash +cd tests + +# build (necessário na primeira vez ou após mudanças no Dockerfile/composer) +docker compose build + +# suíte inteira do plugin +docker compose -f docker-compose.yml -f ../src/plugins/MultipleLocalAuth/tests/docker-compose.yml \ + run --rm mapas pu /var/www/tests/MultipleLocalAuth + +# um arquivo +docker compose -f docker-compose.yml -f ../src/plugins/MultipleLocalAuth/tests/docker-compose.yml \ + run --rm mapas pu /var/www/tests/MultipleLocalAuth/SmokeTest.php + +# um método +docker compose -f docker-compose.yml -f ../src/plugins/MultipleLocalAuth/tests/docker-compose.yml \ + run --rm mapas pu /var/www/tests/MultipleLocalAuth/SmokeTest.php --filter "nomeDoMetodo" +``` + +O override troca apenas `auth.provider` e `auth.config`; o plugin já consta na lista base de plugins. Aponte o `pu` sempre para `/var/www/tests/MultipleLocalAuth` — apontar para `/var/www/tests` rodaria também os testes do core, que assumem o provedor de teste padrão. + +### Testes unitários isolados + +Regras de conta Gov.br (CPF / e-mail único) não dependem do core e têm testes próprios em `tests/`: ```bash cd plugins/MultipleLocalAuth php composer.phar install # ou: composer install -./vendor/bin/phpunit +./vendor/bin/phpunit tests/GovBrAccountServiceTest.php ``` ## Componentes que acompanham o plugin diff --git a/tests/config.d/auth.php b/tests/config.d/auth.php new file mode 100644 index 0000000..b425b0c --- /dev/null +++ b/tests/config.d/auth.php @@ -0,0 +1,22 @@ + 'Test' que o core define em tests/config.d/auth.php. Sem isso, +// $app->auth não é a instância de MultipleLocalAuth\Provider e nada do plugin é exercitado. +return [ + 'auth.provider' => '\MultipleLocalAuth\Provider', + + // O construtor do Provider usa `$config += [...]`, então as chaves definidas aqui + // preservam seus valores e as demais recebem os defaults. + 'auth.config' => [ + 'salt' => 'multiplelocalauth-tests', + + // Sem estratégias, usingSocialLogin() retorna false e o Opauth não é instanciado. + // O construtor do Opauth lê $_SERVER['HTTP_HOST'] e $_SERVER['REQUEST_URI'] sem + // guard, que não existem sob CLI e poluiriam o bootstrap do PHPUnit com warnings. + 'strategies' => [], + + // Exige validação de conta por e-mail, que é o comportamento sob teste. + 'userMustConfirmEmailToUseTheSystem' => true, + ], +]; diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 0000000..d6a18fd --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,22 @@ +services: + mapas: + volumes: + # Restaura o provedor de autenticação deste plugin na suíte de testes do repositório + # principal, sem editar tests/config.d/ do core. O core força 'auth.provider' => 'Test' + # em tests/config.d/auth.php; "zz-multiplelocalauth.d" ordena depois de "config.d" no + # glob de src/conf/config.php, então as chaves deste diretório vencem. + # Diferente de AldirBlanc e MapasBlame, aqui NÃO é preciso um config.d/plugins.php: + # config/plugins.php já lista MultipleLocalAuth e nenhum config.d de teste sobrescreve + # a chave 'plugins'. + # Caminho relativo ao cwd esperado do `docker compose` (tests/ do repositório principal). + # Monta o DIRETÓRIO (não o arquivo isolado) — bind mount de arquivo único dentro de outro + # bind mount (../config:/var/www/config) falha no Docker Desktop/virtiofs (macOS) quando o + # subdiretório de destino ainda não existe no host. + - ../src/plugins/MultipleLocalAuth/tests/config.d:/var/www/config/zz-multiplelocalauth.d + + # Testes do próprio plugin, montados como subpasta de /var/www/tests (mesmo destino de + # tests/src do core, mapeado por tests/docker-compose.yml). O autoload PSR-4 'Tests\\' => + # '/var/www/tests' (composer.json) resolve "Tests\MultipleLocalAuth\SmokeTest" para + # /var/www/tests/MultipleLocalAuth/SmokeTest.php, então as classes deste plugin podem + # usar/estender Tests\Abstract\TestCase e Tests\Traits\* normalmente. + - ../src/plugins/MultipleLocalAuth/tests/src:/var/www/tests/MultipleLocalAuth diff --git a/tests/src/AccountRegistrationEmailTest.php b/tests/src/AccountRegistrationEmailTest.php new file mode 100644 index 0000000..2fe9dbb --- /dev/null +++ b/tests/src/AccountRegistrationEmailTest.php @@ -0,0 +1,124 @@ +app->clearHooks('mailer.transport'); + $this->app->hook('mailer.transport', function (&$transport) { + $transport = new TestTransport(); + }); + + // App::reset() não restaura a requisição, que precisa existir para o doRegister. + $this->originalRequest = $this->app->request; + $this->originalPost = $_POST; + } + + protected function tearDown(): void + { + App::i()->request = $this->originalRequest; + $_POST = $this->originalPost; + + TestTransport::reset(); + + parent::tearDown(); + } + + private function register(string $email): array + { + $payload = [ + 'name' => 'Cadastro de Teste', + 'email' => $email, + 'cpf' => '111.444.777-35', + 'password' => 'Senha@123', + 'confirm_password' => 'Senha@123', + ]; + + // Request::post() lê a superglobal $_POST, não o corpo da requisição PSR-7, + // então é ela que precisa ser preenchida para exercitar o cadastro. + $_POST = $payload; + $this->app->request = $this->requestFactory->mapasPOST('auth', 'register', [], [], $payload); + + $result = $this->app->auth->doRegister(); + + $this->assertTrue( + $result['success'] ?? false, + 'Cadastro rejeitado: ' . json_encode($result['errors'] ?? [], JSON_UNESCAPED_UNICODE) + ); + + return $result; + } + + private function lastEmailBody(): string + { + return TestTransport::getLastMessage()->getOriginalMessage()->getHtmlBody(); + } + + function testCadastroEnviaUmEmailDeValidacaoParaOEnderecoInformado() + { + $email = 'cadastro-' . uniqid() . '@example.test'; + + $result = $this->register($email); + + $this->assertTrue($result['success']); + $this->assertSame(1, TestTransport::getMessagesCount()); + + $message = TestTransport::getLastMessage()->getOriginalMessage(); + + $this->assertSame($email, $message->getTo()[0]->getAddress()); + } + + /** + * Amarra as duas extrações: o token que vai no link tem que ser o mesmo que + * ficou gravado no usuário, senão o link enviado não valida conta nenhuma. + */ + function testTokenDoLinkEOMesmoQueFicaGravadoNoUsuario() + { + $email = 'cadastro-' . uniqid() . '@example.test'; + + $this->register($email); + + $user = $this->app->repo('User')->findOneBy(['email' => $email]); + $token = $user->getMetadata(Provider::$tokenVerifyAccountMetadata); + + $this->assertNotEmpty($token); + $this->assertStringContainsString('confirma-email?token=' . $token, $this->lastEmailBody()); + } + + function testContaNasceAguardandoValidacao() + { + $email = 'cadastro-' . uniqid() . '@example.test'; + + $this->register($email); + + $user = $this->app->repo('User')->findOneBy(['email' => $email]); + + $this->assertSame('0', $user->getMetadata(Provider::$accountIsActiveMetadata)); + $this->assertFalse($this->app->auth->isAccountValidated($user)); + } +} diff --git a/tests/src/AccountValidationEmailTest.php b/tests/src/AccountValidationEmailTest.php new file mode 100644 index 0000000..aa7ad7a --- /dev/null +++ b/tests/src/AccountValidationEmailTest.php @@ -0,0 +1,175 @@ +app->clearHooks('mailer.transport'); + $this->app->hook('mailer.transport', function (&$transport) { + $transport = new TestTransport(); + }); + } + + protected function tearDown(): void + { + TestTransport::reset(); + + parent::tearDown(); + } + + private function setAccountIsActive(User $user, string $value): void + { + $this->app->disableAccessControl(); + $user->setMetadata(Provider::$accountIsActiveMetadata, $value); + $user->saveMetadata(true); + $this->app->enableAccessControl(); + } + + /** + * O corpo vai como HTML (App::createMailMessage converte 'body' em html()), e + * getHtmlBody() devolve o conteúdo original — toString() traria quoted-printable, + * com o '=' virando '=3D' e quebras de linha no meio da URL. + */ + private function lastEmailBody(): string + { + return TestTransport::getLastMessage()->getOriginalMessage()->getHtmlBody(); + } + + function testProvedorSuportaValidacaoDeContaQuandoAConfirmacaoEExigida() + { + $this->assertTrue($this->app->auth->supportsAccountValidation()); + } + + function testProvedorNaoSuportaValidacaoDeContaQuandoAConfirmacaoNaoEExigida() + { + $provider = new TestableProvider(['userMustConfirmEmailToUseTheSystem' => false]); + + $this->assertFalse($provider->supportsAccountValidation()); + } + + /** + * O default da chave é false, então uma instalação que não a configura não + * oferece o reenvio. + */ + function testProvedorNaoSuportaValidacaoDeContaSemAConfiguracao() + { + $provider = new TestableProvider([]); + + $this->assertFalse($provider->supportsAccountValidation()); + } + + function testContaComMetadadoZeroNaoEstaValidada() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '0'); + + $this->assertFalse($this->app->auth->isAccountValidated($user)); + } + + function testContaComMetadadoUmEstaValidada() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '1'); + + $this->assertTrue($this->app->auth->isAccountValidated($user)); + } + + /** + * Usuários anteriores à validação por e-mail não têm o metadado e não são + * bloqueados no login, então não podem ser tratados como pendentes. + */ + function testContaSemOMetadadoEstaValidada() + { + $user = $this->userDirector->createUser(); + + $this->assertTrue($this->app->auth->isAccountValidated($user)); + } + + function testReenvioEnviaEmailParaOEnderecoDoUsuario() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '0'); + + $this->assertTrue($this->app->auth->resendAccountValidationEmail($user)); + $this->assertSame(1, TestTransport::getMessagesCount()); + + $message = TestTransport::getLastMessage()->getOriginalMessage(); + + $this->assertSame($user->email, $message->getTo()[0]->getAddress()); + $this->assertStringContainsString('Bem-vindo ao', $message->getSubject()); + } + + function testReenvioUsaOTokenJaExistente() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '0'); + + $this->app->disableAccessControl(); + $user->setMetadata(Provider::$tokenVerifyAccountMetadata, 'token-existente'); + $user->saveMetadata(true); + $this->app->enableAccessControl(); + + $this->app->auth->resendAccountValidationEmail($user); + + $this->assertSame('token-existente', $user->getMetadata(Provider::$tokenVerifyAccountMetadata)); + $this->assertStringContainsString('confirma-email?token=token-existente', $this->lastEmailBody()); + } + + function testReenvioGeraEPersisteTokenQuandoOUsuarioNaoTemNenhum() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '0'); + + $this->assertNull($user->getMetadata(Provider::$tokenVerifyAccountMetadata)); + + $this->app->auth->resendAccountValidationEmail($user); + + $token = $user->getMetadata(Provider::$tokenVerifyAccountMetadata); + + $this->assertNotEmpty($token); + $this->assertStringContainsString('confirma-email?token=' . $token, $this->lastEmailBody()); + } + + /** + * O e-mail é apenas esvaziado em memória: a coluna usr.email é NOT NULL, e + * persistir o estado inválido fecharia o EntityManager para os demais testes. + */ + function testReenvioNaoEnviaNadaSemEnderecoDeEmail() + { + $user = $this->userDirector->createUser(); + $user->email = ''; + + $this->assertFalse($this->app->auth->resendAccountValidationEmail($user)); + $this->assertSame(0, TestTransport::getMessagesCount()); + } + + function testEmailDeValidacaoIdentificaOUsuarioPeloNomeDoPerfil() + { + $user = $this->userDirector->createUser(); + $this->setAccountIsActive($user, '0'); + + $this->app->auth->resendAccountValidationEmail($user); + + $this->assertStringContainsString($user->profile->name, $this->lastEmailBody()); + } +} diff --git a/tests/src/Doubles/TestableProvider.php b/tests/src/Doubles/TestableProvider.php new file mode 100644 index 0000000..ab1ae13 --- /dev/null +++ b/tests/src/Doubles/TestableProvider.php @@ -0,0 +1,18 @@ +assertArrayHasKey('MultipleLocalAuth', $this->app->plugins); + $this->assertInstanceOf(Plugin::class, $this->app->plugins['MultipleLocalAuth']); + } + + function testProvedorDeAutenticacaoEODoPlugin() + { + $this->assertInstanceOf(Provider::class, $this->app->auth); + } + + function testValidacaoDeContaPorEmailEstaExigida() + { + $this->assertTrue($this->app->config['auth.config']['userMustConfirmEmailToUseTheSystem']); + } + + function testAutenticacaoDaSuiteFuncionaComOProvedorDoPlugin() + { + $user = $this->userDirector->createUser(); + + $this->login($user); + + $this->assertSame($user->id, $this->app->user->id); + } +}