Solucionado (ver solução)
Solucionado
(ver solução)
7
respostas

[Bug] Erro para testar token - Classe LoginController - Cenário 200

Oi! Quando eu fui testar a classe, na hora de eu tentar debugar o código, percebi, no mock, que não está pegando o "secret" que o token deveria gerar, quando executa no método principal, retornando null. Insira aqui a descrição dessa imagem para ajudar na acessibilidadeO mock que ajustei atualmente foi feito dessa forma:

       DadosCadastroUsuario dadosCadastroUsuario = new DadosCadastroUsuario(
                "Nome Cadastro Usuario",
                "user.test@plano.com",
                "12345",
                true
        );

        List<Perfil> perfis = new ArrayList<>();

        var usuario = new Usuario(dadosCadastroUsuario, "12345", perfis);

        // Mock do TokenService para retornar um token fictício
        when(tokenService.generateToken(usuario)).thenReturn("token_ficticio");

O método em si, eu tinha feito os ajustes, conforme as orientações passadas, mas sempre para na questão da autenticação, que não consigo simular. https://cursos.alura.com.br/forum/topico-duvida-falha-ao-testar-codigo-200-login-controller-414186 Só consigo, no máximo, reproduzir, o cenário de erro 401, porque não requer o token. Conseguem me ajudar, por favor, como que eu faço para simular, para receber o token? Como está o código atual (testes):

    @Test
    @DisplayName("Deveria devolver código http 200, quando as informações estiverem válidas")
    @WithMockUser
    void efetuateLoginCenario2() throws Exception {
        var dadosAutenticacao = new DadosAutenticacao("user.test@plano.saude", "12345");

        DadosCadastroUsuario dadosCadastroUsuario = new DadosCadastroUsuario(
                "Nome Cadastro Usuario",
                "user.test@plano.com",
                "12345",
                true
        );

        List<Perfil> perfis = new ArrayList<>();

        var usuario = new Usuario(dadosCadastroUsuario, "12345", perfis);

        // Mock do TokenService para retornar um token fictício
        when(tokenService.generateToken(usuario)).thenReturn("token_ficticio");

        // Mock do AuthenticationManager para simular uma autenticação bem-sucedida
        Authentication authentication = mock(Authentication.class);

        when(authentication.isAuthenticated()).thenReturn(true);
        when(authenticationManager.authenticate(any(Authentication.class))).thenReturn(authentication);

        var response = mvc.perform(post("/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(dadosAutenticacaoJson.write(dadosAutenticacao).getJson()))
                .andReturn().getResponse();

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());

        var jsonRetorno = dadosTokenJson.write(new DadosToken("token_ficticio")).getJson();

        assertThat(response.getContentAsString()).isEqualTo(jsonRetorno);
    }
7 respostas

Oi!

Manda aqui o código da sua classe LoginController

Olá Alexandre e professor Rodrigo,

Anteriormente em uma outra postagem o Alexandre chegou a compartilhar o projeto no github, então me baseei no que está lá e tentei resolver esse problema aí... Pra mim funcionou e faz sentido, gostaria também de saber o que vocês dois acham disso.

Eu tentei ao máximo não utilizar mocks para que o fluxo da autenticação seguisse normalmente. Pra chegar nesse fluxo eu me baseei na documentação do SpringSecurity: https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html, ilustração dessa página ilustra bem o que eu considerei para criar o teste

Na minha implementação utilizei as dependências e mocks:

    @Autowired
    private MockMvc mvc;
    @Autowired
    private BCryptPasswordEncoder encoder;
    @Autowired
    private JacksonTester<DadosAutenticacao> dadosAutenticacaoJson;
    @Autowired
    private JacksonTester<DadosToken> dadosTokenJson;
    
    @MockBean
    private UsuarioService usuarioService; // 

O código em si ficou assim:

    @Test
    @DisplayName("Deveria devolver código http 200, quando as informações estiverem válidas")
    @WithMockUser
    void efetuateLoginCenario2() throws Exception {
        /* Este aqui substituirá o usuário que deveria ser recuperado do banco de dados.
         *
         * Se não criptografar a senha, não irá funcionar, pois o DaoAuthenticationProvider 
         * (ele é chamado pelo AuthenticationManager) está esperando uma senha criptografada 
         * para o objeto que é recuperado do banco de dados.
         */
        Usuario usuario = Usuario.builder()
                .id(1L)
                .nome("Senhor Usuario")
                .login("user.test@plano.saude")
                .password(encoder.encode("12345"))
                .build();
        
        // será o requestBody
        var dadosAutenticacao = new DadosAutenticacao("user.test@plano.saude", "12345");
        
        /* Nesse projeto a classe UsuarioService implementa a interface UserDetailsService.
         * 
         * O método `authenticate` do AuthenticationManager, por baixo dos panos faz 
         * uma chamada ao `loadUserByUsername` a partir da interface UserDetailsService 
         * para recuperar o usuário do banco de dados e comparar com os dados enviados 
         * pelo cliente.
         * 
         * Como esse é um teste de unidade, esse passo será mockado para ceder os 
         * dados necessários para o resto da lógica de autenticação fluir.
         * */
        when(usuarioService.loadUserByUsername(any())).thenReturn(usuario);
        
        var response = mvc
                .perform(
                        post("/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(dadosAutenticacaoJson.write(dadosAutenticacao).getJson())
                        )
                .andReturn().getResponse();
        
        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());

Nessa versão, todo o fluxo de authenticação vai fluir de forma realista, passando pela autenticação e até a geração de um token JWT verdadeiro. No código acima eu não validei o token.

Eu particularmente apenas valido o formato do token, para isso utilizo o a lógica:

    /* O JWT é dividido em 3 partes, header, payload e signature, separados 
     * pelo caractere de ponto. Por isso é necessário validar se foi possível 
     * dividir ele em 3 partes.
     * 
     * O JWT é codificado em Base64Url, por isso usamos o Base64 URL decoder 
     * para decodificar as partes, onde, caso a decodificação falhe, é lançada
     * uma exceção que é capturada para retornar false, negando a validade do token. 
     * */
    boolean isFormatoValidoDeToken(List<String> tokenParts) {
        if (tokenParts.size() != 3) return false;
        
        try {
            tokenParts.forEach(part -> Base64.getUrlDecoder().decode(part));
            return true;
            
        } catch(IllegalArgumentException ex) {
            return false;
        }
    }

Assim podemos fazer mais uma assertion que irá validar se o formato do token é válido:

        var token = dadosTokenJson
                .parseObject(response.getContentAsString())
                .token();
        List<String> tokenParts = List.of(token.split("\\."));
        assertThat(this.isFormatoValidoDeToken(tokenParts)).isTrue();

Também pode ser adicionada uma lógica para validação do token, onde será validado se o token de fato é valido ou se está vencido e etc... Mas - me corrijam se eu estiver errado -, dependendo de o que você quer fazer, pode fazer mais sentido a validação do token ser feita num teste de unidade para o TokenService, já que a responsabilidade dele é gerar tokens válidos.

A um tempo atrás eu detalhei para um colega como funciona esse processo de autenticação, isso pode clarear melhor a lógica por trás do meu teste: https://cursos.alura.com.br/forum/topico-duvida-diferenca-entre-a-classe-userdetailsservice-e-controller-que-authenticationcontroller-383379

A lógica base foi essa, o Alexandre pode ajustar para atender às suas especificidades.

Espero que isso ajude

Oi Mateus, oi professor! A primeira solução proposta pelo Mateus deu certo, fazendo um mock do usuarioService, diferente do que eu tinha feito, que não estava simulando. Obrigado pela ajuda. Antes de finalizar, tenho mais duas dúvidas que gostaria de tirar sobre a segunda solução, para testar a validade do token, proposta pelo Mateus.

  1. A primeira pergunta é a seguinte. Observei que a questão em si foi abordada para validar a autenticidade do token. Isso quer dizer que a lógica proposta também vale para testar tanto a classe LoginController, quanto a classe TokenService, sobre a geração do token, incluindo o tempo de sessão, tamanho e por aí vai, para ambas as classes?

  2. A segunda pergunta é. Para o método abaixo, a lista que o método está recebendo corresponde ao import abaixo?

        boolean isFormatoValidoDeToken(List<String> tokenParts) {
            if (tokenParts.size() != 3)
                return false;

            try {
                tokenParts.forEach(part -> Base64.getUrlDecoder().decode(part));
                return true;

            } catch(IllegalArgumentException ex) {
                return false;
            }
        }

Seria este método a ser importado na classe de teste?

import static jdk.internal.org.jline.reader.impl.LineReaderImpl.CompletionType.List;

Oi Alexandre,

Sobre sua primeira pergunta, o import correto é java.util.List. Eu fiz assim por preferência, você pode fazer o método isFormatoValidoDeToken receber um array de Strings. O que eu fiz foi quebrar o token em 3 strings diferentes a partir dos pontos na String dele no método split(), isso me retornou um array de Strings, então por uma questão de preferência eu transformei esse array num List<String>.

Em um projeto meu eu tinha feito assim:

    public static boolean isValidTokenFormat(String token) {
        String[] parts = token.split("\\.");

        if(parts.length != 3) return false;

        try {
            Base64.getUrlDecoder().decode(parts[0]);
            Base64.getUrlDecoder().decode(parts[1]);
            Base64.getUrlDecoder().decode(parts[2]);
            return true; 

        } catch (IllegalArgumentException ex) {
            return false;
        }
    }

Sobre a segunda pergunta...

O método isFormatoValidoDeToken não verifica a validade do token, ele apenas verifica se o token está no formato que se espera de um JWT. Lá no seu TokenService você já possui um método que valida o token. Então, você pode fazer testes mais específicos e pertinentes ao token em testes de unidade destinado ao TokenService, como validar se o token gerado é decodificavel, validar se o token decodificado retorna o subject esperado, se retorna data de expiração esperada... Esses testes a meu ver não seriam pertinentes ao teste do controller e, uma vez que testado o TokenService, não tem necessidade de fazer os mesmos testes no controller.

Mateus,

Estou começando a entender como é feito teste, ajustando também o import e a regra como tinha aplicado para a lógica. Só que eu tomei um erro de NullPointerException, porque não está recebendo o token. Tentei mudar, tentando buscar pela autenticacao ou pelo usuario, mas não resolveu. Nesse caso, como que eu faria para testar? Erro:

java.lang.NullPointerException: Cannot invoke "String.split(String)" because "tokens" is null

    at com.plano.saude.cadastro.controller.LoginControllerTest.efetuateLoginCenario2(LoginControllerTest.java:141)
    at java.base/java.lang.reflect.Method.invoke(Method.java:578)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

Código:

    @Test
    @DisplayName("Deveria devolver código http 200, quando as informações estiverem válidas")
    @WithMockUser
    void efetuateLoginCenario2() throws Exception {
        /* Este aqui substituirá o usuário que deveria ser recuperado do banco de dados.
         *
         * Se não criptografar a senha, não irá funcionar, pois o DaoAuthenticationProvider
         * (ele é chamado pelo AuthenticationManager) está esperando uma senha criptografada
         * para o objeto que é recuperado do banco de dados.
         */
        Usuario usuario = Usuario.builder()
                .id(1L)
                .nome("Senhor Usuario")
                .login("user.test@plano.saude")
                .password(encoder.encode("12345"))
                .build();

        // Será o requestBody
        var dadosAutenticacao = new DadosAutenticacao("user.test@plano.saude", "12345");

        /* Nesse projeto a classe UsuarioService implementa a interface UserDetailsService.
         *
         * O método `authenticate` do AuthenticationManager, por baixo dos panos faz
         * uma chamada ao `loadUserByUsername` a partir da interface UserDetailsService
         * para recuperar o usuário do banco de dados e comparar com os dados enviados
         * pelo cliente.
         *
         * Como esse é um teste de unidade, esse passo será mockado para ceder os
         * dados necessários para o resto da lógica de autenticação fluir.
         *
         */
        when(usuarioService.loadUserByUsername(any())).thenReturn(usuario);

        var response = mvc.perform(post("/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(dadosAutenticacaoJson.write(dadosAutenticacao).getJson()))
                .andReturn().getResponse();

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());

        // Dessa forma, podemos utilizar mais uma assertion, para saber se o formato do token é válido:
        var tokens = dadosTokenJson
                .parseObject(response.getContentAsString())
                .token();
        List<String> tokenParts = List.of(tokens.split("\\."));
        assertThat(this.isFormatTokenValid(tokenParts)).isTrue();
    }

    /* Para validar o token, é utilizada a lógica abaixo: */
    /* O JWT é dividido em 3 partes, header, payload e signature, separados
     * pelo caractere de ponto. Por isso é necessário validar se foi possível
     * dividir ele em 3 partes.
     *
     * O JWT é codificado em Base64Url, por isso usamos o Base64 URL decoder
     * para decodificar as partes, onde, caso a decodificação falhe, é lançada
     * uma exceção que é capturada para retornar false, negando a validade do token.
     * */
    boolean isFormatTokenValid(List<String> tokenParts) {
        if (tokenParts.size() != 3)
            return false;

        try {
            tokenParts.forEach(part -> Base64.getUrlDecoder().decode(part));
            return true;

        } catch(IllegalArgumentException ex) {
            return false;
        }
    }
solução!

Veja se você tá com o TokenService mockado, se ele estiver mockado vai retornar null, remova a declaração dele do teste

É verdade, tinha faltado remover a declaração dele e mais um serviço que faltou eu remover da classe. Agora deu certo. Muito obrigado!