]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17389 - Prevent project creation with existing key but different case
authorAntoine Vinot <antoine.vinot@sonarsource.com>
Thu, 29 Sep 2022 08:11:41 +0000 (10:11 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 29 Sep 2022 20:03:15 +0000 (20:03 +0000)
12 files changed:
server/sonar-db-dao/src/main/java/org/sonar/db/component/ComponentDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/component/ComponentMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/component/ComponentMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/component/ComponentDaoTest.java
server/sonar-webserver-api/src/main/java/org/sonar/server/exceptions/BadRequestException.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/component/ComponentUpdater.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/component/NewComponent.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/bitbucketcloud/ImportBitbucketCloudRepoActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/bitbucketserver/ImportBitbucketServerProjectActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/component/ComponentUpdaterTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/component/NewComponentTest.java

index afaba3af0e2844b73051cb7e3ea6b961e5f3b034..b11d2801afdfbebd8285b2792e07e4744f92465c 100644 (file)
@@ -241,6 +241,10 @@ public class ComponentDao implements Dao {
     return Optional.ofNullable(mapper(session).selectByKey(key));
   }
 
+  public Optional<ComponentDto> selectByKeyCaseInsensitive(DbSession session, String key) {
+    return Optional.ofNullable(mapper(session).selectByKeyCaseInsensitive(key));
+  }
+
   public Optional<ComponentDto> selectByKeyAndBranch(DbSession session, String key, String branch) {
     return Optional.ofNullable(mapper(session).selectBranchByKeyAndBranchKey(key, generateBranchKey(key, branch), branch));
   }
index 2d827bee828fccbb1a6754e6bcfa98ab3b76cff8..e62a69a23b73f1109eeb0d3ff352089aebd2b918 100644 (file)
@@ -31,7 +31,10 @@ import org.apache.ibatis.session.RowBounds;
 public interface ComponentMapper {
 
   @CheckForNull
-  ComponentDto selectByKey(String key);
+  ComponentDto selectByKey(@Param("key") String key);
+
+  @CheckForNull
+  ComponentDto selectByKeyCaseInsensitive(@Param("key") String key);
 
   @CheckForNull
   ComponentDto selectBranchByKeyAndBranchKey(@Param("key") String key, @Param("dbKey") String dbKey, @Param("branch") String branch);
@@ -40,7 +43,7 @@ public interface ComponentMapper {
   ComponentDto selectPrByKeyAndBranchKey(@Param("key") String key, @Param("dbKey") String dbKey, @Param("branch") String branch);
 
   @CheckForNull
-  ComponentDto selectByUuid(String uuid);
+  ComponentDto selectByUuid(@Param("uuid") String uuid);
 
   /**
    * Return sub project of component keys
index 05d2e777fe1c641a12994775d5c5be4daa08f4b4..aed19c345e9558757aa5025c7713b2423b5b8052 100644 (file)
       p.kee=#{key,jdbcType=VARCHAR}
   </select>
 
+  <select id="selectByKeyCaseInsensitive" parameterType="String" resultType="Component">
+    SELECT
+      <include refid="componentColumns"/>
+    FROM components p
+    where
+      lower(p.kee)=lower(#{key,jdbcType=VARCHAR})
+  </select>
+
   <select id="selectBranchByKeyAndBranchKey" parameterType="String" resultType="Component">
     select
       <include refid="componentColumns"/>
index c7c7d77f643c63f001fac5556064b3fe0e7777e1..0996f6a56ff47d132ddd04ce07f9ea69781dd3ef 100644 (file)
@@ -1979,6 +1979,27 @@ public class ComponentDaoTest {
     verifyNoInteractions(auditPersister);
   }
 
+  @Test
+  public void selectByKeyCaseInsensitive_shouldFindProject_whenCaseIsDifferent() {
+    String projectKey = randomAlphabetic(5).toLowerCase();
+    db.components().insertPrivateProject(c -> c.setDbKey(projectKey));
+
+    ComponentDto result = underTest.selectByKeyCaseInsensitive(db.getSession(), projectKey.toUpperCase()).orElse(null);
+
+    assertThat(result).isNotNull();
+    assertThat(result.getKey()).isEqualTo(projectKey);
+  }
+
+  @Test
+  public void selectByKeyCaseInsensitive_shouldNotFindProject_whenKeyIsDifferent() {
+    String projectKey = randomAlphabetic(5).toLowerCase();
+    db.components().insertPrivateProject(c -> c.setDbKey(projectKey));
+
+    Optional<ComponentDto> result = underTest.selectByKeyCaseInsensitive(db.getSession(), projectKey + randomAlphabetic(1));
+
+    assertThat(result).isEmpty();
+  }
+
   private boolean privateFlagOfUuid(String uuid) {
     return underTest.selectByUuid(db.getSession(), uuid).get().isPrivate();
   }
index 00f08d4b0bdd09af06113c729b0774e336ccd575..fdf94a4c2d81d1abc4bde319bd2fb6929e92bd1d 100644 (file)
@@ -51,16 +51,20 @@ public class BadRequestException extends ServerException {
     }
   }
 
-  public static BadRequestException create(List<String> errorMessages) {
-    checkArgument(!errorMessages.isEmpty(), "At least one error message is required");
-    checkArgument(errorMessages.stream().noneMatch(message -> message == null || message.isEmpty()), "Message cannot be empty");
-    return new BadRequestException(errorMessages);
+  public static void throwBadRequestException(String message, Object... messageArguments) {
+    throw create(format(message, messageArguments));
   }
 
   public static BadRequestException create(String... errorMessages) {
     return create(asList(errorMessages));
   }
 
+  public static BadRequestException create(List<String> errorMessages) {
+    checkArgument(!errorMessages.isEmpty(), "At least one error message is required");
+    checkArgument(errorMessages.stream().noneMatch(message -> message == null || message.isEmpty()), "Message cannot be empty");
+    return new BadRequestException(errorMessages);
+  }
+
   public List<String> errors() {
     return errors;
   }
index 0a1ff5347ba5267413cd2dd34663782f971750ec..55c76cf9789fc81dad1ed341b5b1d8446f1383e9 100644 (file)
@@ -19,7 +19,6 @@
  */
 package org.sonar.server.component;
 
-import com.google.common.collect.ImmutableSet;
 import java.util.Date;
 import java.util.Locale;
 import java.util.Optional;
@@ -49,10 +48,13 @@ import static org.sonar.api.resources.Qualifiers.PROJECT;
 import static org.sonar.core.component.ComponentKeys.ALLOWED_CHARACTERS_MESSAGE;
 import static org.sonar.core.component.ComponentKeys.isValidProjectKey;
 import static org.sonar.server.exceptions.BadRequestException.checkRequest;
+import static org.sonar.server.exceptions.BadRequestException.throwBadRequestException;
 
 public class ComponentUpdater {
 
-  private static final Set<String> MAIN_BRANCH_QUALIFIERS = ImmutableSet.of(Qualifiers.PROJECT, Qualifiers.APP);
+  private static final Set<String> MAIN_BRANCH_QUALIFIERS = Set.of(Qualifiers.PROJECT, Qualifiers.APP);
+  private static final String KEY_ALREADY_EXISTS_ERROR = "Could not create %s with key: \"%s\". A similar key already exists: \"%s\"";
+  private static final String MALFORMED_KEY_ERROR = "Malformed key for %s: '%s'. %s.";
 
   private final DbClient dbClient;
   private final I18n i18n;
@@ -87,6 +89,10 @@ public class ComponentUpdater {
     return componentDto;
   }
 
+  public void commitAndIndex(DbSession dbSession, ComponentDto componentDto) {
+    projectIndexers.commitAndIndexComponents(dbSession, singletonList(componentDto), Cause.PROJECT_CREATION);
+  }
+
   /**
    * Create component without committing.
    * Don't forget to call commitAndIndex(...) when ready to commit.
@@ -104,6 +110,8 @@ public class ComponentUpdater {
     @Nullable String userUuid, @Nullable String userLogin, @Nullable String mainBranchName,
     Consumer<ComponentDto> componentModifier) {
     checkKeyFormat(newComponent.qualifier(), newComponent.key());
+    checkKeyAlreadyExists(dbSession, newComponent);
+
     ComponentDto componentDto = createRootComponent(dbSession, newComponent, componentModifier);
     if (isRootProject(componentDto)) {
       createMainBranch(dbSession, componentDto.uuid(), mainBranchName);
@@ -112,14 +120,20 @@ public class ComponentUpdater {
     return componentDto;
   }
 
-  public void commitAndIndex(DbSession dbSession, ComponentDto componentDto) {
-    projectIndexers.commitAndIndexComponents(dbSession, singletonList(componentDto), Cause.PROJECT_CREATION);
+  private void checkKeyFormat(String qualifier, String key) {
+    checkRequest(isValidProjectKey(key), MALFORMED_KEY_ERROR, getQualifierToDisplay(qualifier), key, ALLOWED_CHARACTERS_MESSAGE);
   }
 
-  private ComponentDto createRootComponent(DbSession session, NewComponent newComponent, Consumer<ComponentDto> componentModifier) {
-    checkRequest(!dbClient.componentDao().selectByKey(session, newComponent.key()).isPresent(),
-      "Could not create %s, key already exists: %s", getQualifierToDisplay(newComponent.qualifier()), newComponent.key());
+  private void checkKeyAlreadyExists(DbSession dbSession, NewComponent newComponent) {
+    Optional<ComponentDto> componentDto = newComponent.isProject()
+      ? dbClient.componentDao().selectByKeyCaseInsensitive(dbSession, newComponent.key())
+      : dbClient.componentDao().selectByKey(dbSession, newComponent.key());
+
+    componentDto.map(ComponentDto::getKey)
+      .ifPresent(existingKey -> throwBadRequestException(KEY_ALREADY_EXISTS_ERROR, getQualifierToDisplay(newComponent.qualifier()), newComponent.key(), existingKey));
+  }
 
+  private ComponentDto createRootComponent(DbSession session, NewComponent newComponent, Consumer<ComponentDto> componentModifier) {
     long now = system2.now();
     String uuid = uuidFactory.create();
 
@@ -209,10 +223,6 @@ public class ComponentUpdater {
     }
   }
 
-  private void checkKeyFormat(String qualifier, String key) {
-    checkRequest(isValidProjectKey(key), "Malformed key for %s: '%s'. %s.", getQualifierToDisplay(qualifier), key, ALLOWED_CHARACTERS_MESSAGE);
-  }
-
   private String getQualifierToDisplay(String qualifier) {
     return i18n.message(Locale.getDefault(), "qualifier." + qualifier, "Project");
   }
index 1710458e32d5585e8b6dfd5b372a49af4a37b660..b68a9a12e9ec22f80709523141e606b4895cf060 100644 (file)
@@ -69,6 +69,10 @@ public class NewComponent {
     return description;
   }
 
+  public boolean isProject() {
+    return PROJECT.equals(qualifier);
+  }
+
   public static class Builder {
     private String description;
     private String key;
index 6b5d25f5ca82da5eb39bc479fa43dc406aed03f2..ca76ed7b740b8c1bf2019f9a2aa695c0692415b2 100644 (file)
@@ -29,7 +29,6 @@ import org.sonar.alm.client.azure.GsonAzureRepo;
 import org.sonar.api.config.internal.Encryption;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
-import org.sonar.core.i18n.I18n;
 import org.sonar.core.util.SequenceUuidFactory;
 import org.sonar.db.DbTester;
 import org.sonar.db.alm.pat.AlmPatDto;
@@ -47,6 +46,7 @@ import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.exceptions.UnauthorizedException;
 import org.sonar.server.favorite.FavoriteUpdater;
+import org.sonar.server.l18n.I18nRule;
 import org.sonar.server.permission.PermissionTemplateService;
 import org.sonar.server.project.ProjectDefaultVisibility;
 import org.sonar.server.project.Visibility;
@@ -74,10 +74,12 @@ public class ImportAzureProjectActionTest {
   public UserSessionRule userSession = UserSessionRule.standalone();
   @Rule
   public DbTester db = DbTester.create();
+  @Rule
+  public final I18nRule i18n = new I18nRule();
 
   private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class);
 
-  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE,
+  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), i18n, System2.INSTANCE,
     mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory());
 
   private final Encryption encryption = mock(Encryption.class);
@@ -252,7 +254,7 @@ public class ImportAzureProjectActionTest {
 
     assertThatThrownBy(request::execute)
       .isInstanceOf(BadRequestException.class)
-      .hasMessage("Could not create null, key already exists: " + GENERATED_PROJECT_KEY);
+      .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", GENERATED_PROJECT_KEY, GENERATED_PROJECT_KEY);
   }
 
   @Test
index 6e50410f731aff5346ab78a2a59e608659f6e2ee..574859d8c9fcea7b3fc6a22640aef8ca30fa960a 100644 (file)
@@ -29,7 +29,6 @@ import org.sonar.alm.client.bitbucket.bitbucketcloud.Project;
 import org.sonar.alm.client.bitbucket.bitbucketcloud.Repository;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
-import org.sonar.core.i18n.I18n;
 import org.sonar.core.util.SequenceUuidFactory;
 import org.sonar.db.DbTester;
 import org.sonar.db.alm.pat.AlmPatDto;
@@ -47,6 +46,7 @@ import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.exceptions.UnauthorizedException;
 import org.sonar.server.favorite.FavoriteUpdater;
+import org.sonar.server.l18n.I18nRule;
 import org.sonar.server.permission.PermissionTemplateService;
 import org.sonar.server.project.ProjectDefaultVisibility;
 import org.sonar.server.project.Visibility;
@@ -75,11 +75,13 @@ public class ImportBitbucketCloudRepoActionTest {
   public UserSessionRule userSession = UserSessionRule.standalone();
   @Rule
   public DbTester db = DbTester.create();
+  @Rule
+  public final I18nRule i18n = new I18nRule();
 
   private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class);
   private final BitbucketCloudRestClient bitbucketCloudRestClient = mock(BitbucketCloudRestClient.class);
 
-  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE,
+  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), i18n, System2.INSTANCE,
     mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory());
 
   private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession);
@@ -146,7 +148,7 @@ public class ImportBitbucketCloudRepoActionTest {
 
     assertThatThrownBy(request::execute)
       .isInstanceOf(BadRequestException.class)
-      .hasMessageContaining("Could not create null, key already exists: " + GENERATED_PROJECT_KEY);
+      .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", GENERATED_PROJECT_KEY, GENERATED_PROJECT_KEY);
   }
 
   @Test
index ea7af30e4e882e3336951ee285dd0b9390c155f7..97aa314537044d9976ab9189823c3874855814a8 100644 (file)
@@ -35,7 +35,6 @@ import org.sonar.alm.client.bitbucketserver.Project;
 import org.sonar.alm.client.bitbucketserver.Repository;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
-import org.sonar.core.i18n.I18n;
 import org.sonar.core.util.SequenceUuidFactory;
 import org.sonar.db.DbTester;
 import org.sonar.db.alm.pat.AlmPatDto;
@@ -52,6 +51,7 @@ import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.exceptions.UnauthorizedException;
 import org.sonar.server.favorite.FavoriteUpdater;
+import org.sonar.server.l18n.I18nRule;
 import org.sonar.server.permission.PermissionTemplateService;
 import org.sonar.server.project.ProjectDefaultVisibility;
 import org.sonar.server.project.Visibility;
@@ -80,11 +80,13 @@ public class ImportBitbucketServerProjectActionTest {
   public UserSessionRule userSession = UserSessionRule.standalone();
   @Rule
   public DbTester db = DbTester.create();
+  @Rule
+  public final I18nRule i18n = new I18nRule();
 
   private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class);
   private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class);
 
-  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE,
+  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), i18n, System2.INSTANCE,
     mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory());
 
   private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession);
@@ -160,7 +162,7 @@ public class ImportBitbucketServerProjectActionTest {
         .execute();
     })
       .isInstanceOf(BadRequestException.class)
-      .hasMessage("Could not create null, key already exists: " + GENERATED_PROJECT_KEY);
+      .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", GENERATED_PROJECT_KEY, GENERATED_PROJECT_KEY);
   }
 
   @Test
index bb794c4c1305d68cf3da6a570efa47bad64e6601..35b27f35e0298c57ccb54cb7fcc16d4c81ebbed3 100644 (file)
@@ -40,6 +40,7 @@ import org.sonar.server.l18n.I18nRule;
 import org.sonar.server.permission.PermissionTemplateService;
 
 import static java.util.stream.IntStream.rangeClosed;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
@@ -65,7 +66,7 @@ public class ComponentUpdaterTest {
   private final TestProjectIndexers projectIndexers = new TestProjectIndexers();
   private final PermissionTemplateService permissionTemplateService = mock(PermissionTemplateService.class);
 
-  private ComponentUpdater underTest = new ComponentUpdater(db.getDbClient(), i18n, system2,
+  private final ComponentUpdater underTest = new ComponentUpdater(db.getDbClient(), i18n, system2,
     permissionTemplateService,
     new FavoriteUpdater(db.getDbClient()),
     projectIndexers, new SequenceUuidFactory());
@@ -252,7 +253,7 @@ public class ComponentUpdaterTest {
       .build();
     assertThatThrownBy(() -> underTest.create(session, newComponent, null, null))
       .isInstanceOf(BadRequestException.class)
-      .hasMessage("Could not create Project, key already exists: " + existing.getDbKey());
+      .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", existing.getDbKey(), existing.getDbKey());
   }
 
   @Test
@@ -278,4 +279,21 @@ public class ComponentUpdaterTest {
       .isInstanceOf(BadRequestException.class)
       .hasMessageContaining("Malformed key for Project: 'roject%Key'");
   }
+
+  @Test
+  public void create_shouldFail_whenCreatingProjectWithExistingKeyButDifferentCase() {
+    String existingKey = randomAlphabetic(5).toUpperCase();
+    db.components().insertPrivateProject(component -> component.setDbKey(existingKey));
+    String newKey = existingKey.toLowerCase();
+
+    NewComponent newComponent = NewComponent.newComponentBuilder()
+      .setKey(newKey)
+      .setName(DEFAULT_PROJECT_NAME)
+      .build();
+
+    DbSession dbSession = db.getSession();
+    assertThatThrownBy(() -> underTest.create(dbSession, newComponent, null, null))
+      .isInstanceOf(BadRequestException.class)
+      .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", newKey, existingKey);
+  }
 }
index f0d10e6027ea076c79173e33503c6de140a448d4..5e0b7890b65694f32ff57d285b9eedada424536c 100644 (file)
@@ -22,6 +22,7 @@ package org.sonar.server.component;
 import org.junit.Test;
 
 import static com.google.common.base.Strings.repeat;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.sonar.api.resources.Qualifiers.PROJECT;
@@ -120,8 +121,28 @@ public class NewComponentTest {
     assertThat(newComponent.qualifier()).isEqualTo(PROJECT);
   }
 
+  @Test
+  public void isProject_shouldReturnTrue_whenQualifierIsProject() {
+    NewComponent newComponent = underTest.setKey(KEY)
+      .setName(NAME)
+      .setQualifier(PROJECT)
+      .build();
+
+    assertThat(newComponent.isProject()).isTrue();
+  }
+
+  @Test
+  public void isProject_shouldReturnFalse_whenQualifierIsNotProject() {
+    NewComponent newComponent = underTest.setKey(KEY)
+      .setName(NAME)
+      .setQualifier(randomAlphabetic(4))
+      .build();
+
+    assertThat(newComponent.isProject()).isFalse();
+  }
+
   private void expectBuildException(Class<? extends Exception> expectedExceptionType, String expectedMessage) {
-    assertThatThrownBy(() -> underTest.build())
+    assertThatThrownBy(underTest::build)
       .isInstanceOf(expectedExceptionType)
       .hasMessageContaining(expectedMessage);
   }