/*
 * SonarQube
 * Copyright (C) 2009-2024 SonarSource SA
 * mailto:info AT sonarsource DOT com
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
package org.sonar.server.user.ws;

import java.io.IOException;
import java.util.Optional;
import javax.annotation.Nullable;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.sonar.api.config.internal.MapSettings;
import org.sonar.api.server.http.HttpRequest;
import org.sonar.api.server.http.HttpResponse;
import org.sonar.api.server.ws.WebService;
import org.sonar.api.web.FilterChain;
import org.sonar.db.DbSession;
import org.sonar.db.DbTester;
import org.sonar.db.audit.NoOpAuditPersister;
import org.sonar.db.user.SessionTokenDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.authentication.CredentialsLocalAuthentication;
import org.sonar.server.authentication.JwtHttpHandler;
import org.sonar.server.common.management.ManagedInstanceChecker;
import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.tester.UserSessionRule;
import org.sonar.server.user.NewUserNotifier;
import org.sonar.server.user.UserUpdater;
import org.sonar.server.usergroups.DefaultGroupFinder;
import org.sonar.server.ws.ServletFilterHandler;

import static java.lang.String.format;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_LOGIN;
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_PASSWORD;
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_PREVIOUS_PASSWORD;

public class ChangePasswordActionIT {

  private static final String OLD_PASSWORD = "1234";
  private static final String NEW_PASSWORD = "12345";

  @Rule
  public DbTester db = DbTester.create();
  @Rule
  public UserSessionRule userSessionRule = UserSessionRule.standalone().logIn();

  private final ArgumentCaptor<UserDto> userDtoCaptor = ArgumentCaptor.forClass(UserDto.class);

  private final HttpRequest request = mock(HttpRequest.class);
  private final HttpResponse response = mock(HttpResponse.class);
  private final FilterChain chain = mock(FilterChain.class);

  private final MapSettings settings = new MapSettings().setProperty("sonar.internal.pbkdf2.iterations", "1");
  private final CredentialsLocalAuthentication localAuthentication = new CredentialsLocalAuthentication(db.getDbClient(), settings.asConfig());

  private final UserUpdater userUpdater = new UserUpdater(mock(NewUserNotifier.class), db.getDbClient(),
    new DefaultGroupFinder(db.getDbClient()),
    new MapSettings().asConfig(), new NoOpAuditPersister(), localAuthentication);

  private final JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class);

  private final ManagedInstanceChecker managedInstanceChecker = mock(ManagedInstanceChecker.class);

  private final ChangePasswordAction underTest = new ChangePasswordAction(db.getDbClient(), userUpdater, userSessionRule, localAuthentication, jwtHttpHandler, managedInstanceChecker);
  private ServletOutputStream responseOutputStream;

  @Before
  public void setUp() throws IOException {
    db.users().insertDefaultGroup();
    responseOutputStream = new StringOutputStream();
    doReturn(responseOutputStream).when(response).getOutputStream();
  }

  @Test
  public void a_user_can_update_his_password() {
    UserTestData user = createLocalUser(OLD_PASSWORD);
    String oldCryptedPassword = findEncryptedPassword(user.getLogin());
    userSessionRule.logIn(user.userDto());

    executeTest(user.getLogin(), OLD_PASSWORD, NEW_PASSWORD);

    String newCryptedPassword = findEncryptedPassword(user.getLogin());
    assertThat(newCryptedPassword).isNotEqualTo(oldCryptedPassword);
    verify(jwtHttpHandler).removeToken(request, response);
    verify(jwtHttpHandler).generateToken(userDtoCaptor.capture(), eq(request), eq(response));
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isEmpty();
    assertThat(userDtoCaptor.getValue().getLogin()).isEqualTo(user.getLogin());
    verify(response).setStatus(HTTP_NO_CONTENT);
  }

  @Test
  public void system_administrator_can_update_password_of_user() {
    UserTestData admin = createLocalUser();
    userSessionRule.logIn(admin.userDto()).setSystemAdministrator();
    UserTestData user = createLocalUser();
    String originalPassword = findEncryptedPassword(user.getLogin());
    db.commit();

    executeTest(user.getLogin(), null, NEW_PASSWORD);

    String newPassword = findEncryptedPassword(user.getLogin());
    assertThat(newPassword).isNotEqualTo(originalPassword);
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isEmpty();
    assertThat(findSessionTokenDto(db.getSession(), admin.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
    verify(response).setStatus(HTTP_NO_CONTENT);
  }

  private String findEncryptedPassword(String login) {
    return db.getDbClient().userDao().selectByLogin(db.getSession(), login).getCryptedPassword();
  }

  private Optional<SessionTokenDto> findSessionTokenDto(DbSession dbSession, String tokenUuid) {
    return db.getDbClient().sessionTokensDao().selectByUuid(dbSession, tokenUuid);
  }

  @Test
  public void fail_to_update_someone_else_password_if_not_admin() {
    UserTestData user = createLocalUser();
    userSessionRule.logIn(user.getLogin());
    UserTestData anotherLocalUser = createLocalUser();

    String anotherLocalUserLogin = anotherLocalUser.getLogin();
    assertThatThrownBy(() -> executeTest(anotherLocalUserLogin, "I dunno", NEW_PASSWORD))
      .isInstanceOf(ForbiddenException.class);
    verifyNoInteractions(jwtHttpHandler);
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_someone_else_password_if_not_admin_and_user_doesnt_exist() {
    UserTestData user = createLocalUser();
    userSessionRule.logIn(user.getLogin());

    assertThatThrownBy(() -> executeTest("unknown", "I dunno", NEW_PASSWORD))
      .isInstanceOf(ForbiddenException.class);
    verifyNoInteractions(jwtHttpHandler);

    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_unknown_user() {
    UserTestData admin = createLocalUser();
    userSessionRule.logIn(admin.userDto()).setSystemAdministrator();

    assertThatThrownBy(() -> executeTest("polop", null, "polop"))
      .isInstanceOf(NotFoundException.class)
      .hasMessage("User with login 'polop' has not been found");
    assertThat(findSessionTokenDto(db.getSession(), admin.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_on_disabled_user() {
    UserDto user = db.users().insertUser(u -> u.setActive(false));
    userSessionRule.logIn(user);

    String userLogin = user.getLogin();
    assertThatThrownBy(() -> executeTest(userLogin, null, "polop"))
      .isInstanceOf(NotFoundException.class)
      .hasMessage(format("User with login '%s' has not been found", userLogin));
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_password_on_self_without_login() {
    when(request.getParameter(PARAM_PASSWORD)).thenReturn("new password");
    when(request.getParameter(PARAM_PREVIOUS_PASSWORD)).thenReturn(NEW_PASSWORD);

    executeTest(null, OLD_PASSWORD, NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"The 'login' parameter is missing\"}");
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_password_on_self_without_old_password() {
    UserTestData user = createLocalUser();
    userSessionRule.logIn(user.userDto());

    executeTest(user.getLogin(), null, NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"The 'previousPassword' parameter is missing\"}");
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_password_on_self_without_new_password() {
    UserTestData user = createLocalUser();
    userSessionRule.logIn(user.userDto());

    executeTest(user.getLogin(), OLD_PASSWORD, null);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"The 'password' parameter is missing\"}");
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_password_on_self_with_bad_old_password() {
    UserTestData user = createLocalUser();
    userSessionRule.logIn(user.userDto());

    executeTest(user.getLogin(), "I dunno", NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"old_password_incorrect\"}");
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void fail_to_update_password_on_external_auth() {
    UserDto admin = db.users().insertUser();
    userSessionRule.logIn(admin).setSystemAdministrator();
    UserDto user = db.users().insertUser(u -> u.setLocal(false));

    executeTest(user.getLogin(), "I dunno", NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"Password cannot be changed when external authentication is used\"}");
  }

  @Test
  public void fail_to_update_to_same_password() {
    UserTestData user = createLocalUser(OLD_PASSWORD);
    userSessionRule.logIn(user.userDto());

    executeTest(user.getLogin(), OLD_PASSWORD, OLD_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"new_password_same_as_old\"}");
    assertThat(findSessionTokenDto(db.getSession(), user.getSessionTokenUuid())).isPresent();
    verifyNoInteractions(jwtHttpHandler);
  }

  @Test
  public void changePassword_whenInstanceIsManagedAndUserUpdate_shouldThrow() {
    doThrow(BadRequestException.create("Operation not allowed when the instance is externally managed.")).when(managedInstanceChecker).throwIfInstanceIsManaged();

    UserTestData user = createLocalUser(OLD_PASSWORD);
    userSessionRule.logIn(user.userDto());
    executeTest(user.getLogin(), OLD_PASSWORD, NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"Operation not allowed when the instance is externally managed.\"}");
  }

  @Test
  public void changePassword_whenInstanceIsManagedAndAdminUpdate_shouldThrow() {
    doThrow(BadRequestException.create("Operation not allowed when the instance is externally managed.")).when(managedInstanceChecker).throwIfInstanceIsManaged();

    UserDto admin = db.users().insertUser();
    userSessionRule.logIn(admin).setSystemAdministrator();
    UserDto user = db.users().insertUser(u -> u.setLocal(false));

    executeTest(user.getLogin(), OLD_PASSWORD, NEW_PASSWORD);
    verify(response).setStatus(HTTP_BAD_REQUEST);
    assertThat(responseOutputStream).hasToString("{\"result\":\"Operation not allowed when the instance is externally managed.\"}");
  }

  @Test
  public void verify_definition() {
    String controllerKey = "foo";
    WebService.Context context = new WebService.Context();
    WebService.NewController newController = context.createController(controllerKey);

    underTest.define(newController);
    newController.done();

    WebService.Action changePassword = context.controller(controllerKey).action("change_password");
    assertThat(changePassword).isNotNull();
    assertThat(changePassword.handler()).isInstanceOf(ServletFilterHandler.class);
    assertThat(changePassword.isPost()).isTrue();
    assertThat(changePassword.params())
      .extracting(WebService.Param::key)
      .containsExactlyInAnyOrder(PARAM_LOGIN, PARAM_PASSWORD, PARAM_PREVIOUS_PASSWORD);
  }

  private void executeTest(@Nullable String login, @Nullable String oldPassword, @Nullable String newPassword) {
    when(request.getParameter(PARAM_LOGIN)).thenReturn(login);
    when(request.getParameter(PARAM_PREVIOUS_PASSWORD)).thenReturn(oldPassword);
    when(request.getParameter(PARAM_PASSWORD)).thenReturn(newPassword);
    underTest.doFilter(request, response, chain);
  }

  private UserTestData createLocalUser(String password) {
    UserTestData userTestData = createLocalUser();
    localAuthentication.storeHashPassword(userTestData.userDto(), password);
    db.getDbClient().userDao().update(db.getSession(), userTestData.userDto());
    db.commit();
    return userTestData;
  }

  private UserTestData createLocalUser() {
    UserDto userDto = db.users().insertUser(u -> u.setLocal(true));
    SessionTokenDto sessionTokenForUser = createSessionTokenForUser(userDto);
    db.commit();
    return new UserTestData(userDto, sessionTokenForUser);
  }

  private SessionTokenDto createSessionTokenForUser(UserDto user) {
    SessionTokenDto userTokenDto = new SessionTokenDto().setUserUuid(user.getUuid()).setExpirationDate(1000L);
    return db.getDbClient().sessionTokensDao().insert(db.getSession(), userTokenDto);
  }

  private record UserTestData(UserDto userDto, SessionTokenDto sessionTokenDto) {

    String getLogin() {
      return userDto.getLogin();
    }

    String getSessionTokenUuid() {
      return sessionTokenDto.getUuid();
    }
  }

  static class StringOutputStream extends ServletOutputStream {
    private final StringBuilder buf = new StringBuilder();

    StringOutputStream() {
    }

    @Override
    public boolean isReady() {
      return false;
    }

    @Override
    public void setWriteListener(WriteListener listener) {

    }

    public void write(byte[] b) {
      this.buf.append(new String(b));
    }

    public void write(byte[] b, int off, int len) {
      this.buf.append(new String(b, off, len));
    }

    public void write(int b) {
      byte[] bytes = new byte[] {(byte) b};
      this.buf.append(new String(bytes));
    }

    public String toString() {
      return this.buf.toString();
    }
  }

}
