Um pequeno post sobre como resolvi o issue #14 na API do WordView.

O Problema Link para o cabeçalho

O salt da senha é gerado através da combinação de email + senha, então atualizar somente o email quebra o sistema de login, quando o usuário que alterou o seu email tentar novamente entrar na conta o hash de login não vai ser igual ao armazenado no servidor.

@PutMapping("/me")
public ResponseEntity<?> update(@RequestBody UserUpdateRequest request, HttpServletRequest req) {
	return response(() -> {
		User user = service.getMe(req);
		User userAlter = request.toEntity();

		// o método merge escreve os valores de 'userAlter' sobre os de 'user'
		User merged = ClassMerger.merge(user, userAlter);

		// insert salva os valores alterados
		service.insert(merged);

		return ok();
	});
}

Solucionando Link para o cabeçalho

Eu comecei por criar um novo tipo de request e o teste para ele. Enquanto eu criava esse teste percebi que todos os request tests são muito parecidos e que seria benéfico refatorar e simplicá-los, então criei um novo issue para trabalhar nessa questão depois.

@Getter
@Setter
public class UserEmailUpdateRequest {
    private String oldEmail;
    private String newEmail;
    private String password;
// ...
}

Para manter o sistema simples decidi separar a atualização do email da atualização das demais informações, juntar tudo em um só endpoint adicionaria uma complexidade a mais.

A idéia é simples: Eu crio dois hashes (com o novo e com o velho email), comparo se o velho está de acordo com o que está armazenado e se sim eu altero o email e a senha com os novos valores.

@PutMapping("/me/email")
public ResponseEntity<?> updateEmail(@RequestBody UserEmailUpdateRequest request, HttpServletRequest req) {					
	return response(() -> {
		request.validate();

		User user = service.getMe(req);

		String oldHash = new HashedPassword(request.getOldEmail(), request.getPassword()).getValue();
		String newHash = new HashedPassword(request.getNewEmail(), request.getPassword()).getValue();

		if (user.getPassword().equals(oldHash)) {
			user.setEmail(request.getNewEmail());
			user.setPassword(newHash);
		} else {
			throw new IncorrectCredentialsException("Email or password did not match");
		}

		service.insert(user);

		return ok();
	});
}

O teste também é simples: Eu faço login na conta de teste, para garantir que o login esta funcionando corretamente, altero o email da conta, e tento fazer o login novamente usando o novo email.

class UserControllerEmailUpdateTest extends ControllerTest {
    @Test
    @Order(1)
    void login() throws Exception {
        MockUser user = new MockUser("mock.user@gmail.com", "S_enha64");

        req.post("/user/login", user.toJson()).andExpect(status().isOk());
    }

    @Test
    @Order(2)
    void updateEmail() throws Exception {
        String jwt = MockValues.getUserJwt(mockMvc);
        MockEmailUpdateRequest request = new MockEmailUpdateRequest("mock.user@gmail.com", "new.email@gmail.com", "S_enha64");

        req.put("/user/me/email", request.toJson(), jwt).andExpect(status().isOk());
    }

    @Test
    @Order(3)
    void loginNewEmail() throws Exception {
        MockUser user = new MockUser("new.email@gmail.com", "S_enha64");

        req.post("/user/login", user.toJson()).andExpect(status().isOk());
    }
}

Enquanto criava o teste percebi que faltava algo: Ver se o email novo já estava sendo usado por outra conta.

Por conta dessa lógica adicional, acabou ficando muito ‘caro’ manter a lógica toda no Controller, então decidi mover tudo para o UserService

@PutMapping("/me/email")
public ResponseEntity<?> updateEmail(@RequestBody UserEmailUpdateRequest request, HttpServletRequest req) {
	return response(() -> {
		request.validate();

		service.insertWithNewEmail(req, request.getNewEmail(), request.getOldEmail(), request.getPassword());

		return ok();
	});
}
@Override
public User insertWithNewEmail(HttpServletRequest request, String newEmail, String oldEmail, String password) throws ValueTakenException, InvalidKeySpecException, NoSuchEntryException, IncorrectCredentialsException {
    Optional<User> existingUserWithNewEmail = repository.findByEmail(newEmail);

    if (existingUserWithNewEmail.isPresent()) {
        throw new ValueTakenException("newEmail is already taken by another account.");
    }

    User user = getMe(request);

    String oldHash = new HashedPassword(oldEmail, password).getValue();
    String newHash = new HashedPassword(newEmail, password).getValue();

    if (user.getPassword().equals(oldHash)) {
        user.setEmail(newEmail);
        user.setPassword(newHash);
    } else {
        throw new IncorrectCredentialsException("Email or password did not match");
    }

    return insert(user);
}

Com a nova variável, a ordem do teste fica assim: Login, tentar alterar o email com um email já em uso, alterar para um email novo, fazer login com o email novo.

@Test
@Order(2)
void updateExistingEmail() throws Exception {
    String jwt = MockValues.getUserJwt(mockMvc);
    MockEmailUpdateRequest request = new MockEmailUpdateRequest("mock.user@gmail.com", "mock.admin@gmail.com", "S_enha64");

    req.put("/user/me/email", request.toJson(), jwt).andExpect(status().isForbidden());
}

Com isso, eu rodo todos os testes e pronto!