]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16397 Add DB migration to migrate rule descriptions to new structure
authorAurelien Poscia <aurelien.poscia@sonarsource.com>
Mon, 30 May 2022 10:05:19 +0000 (12:05 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 31 May 2022 20:02:50 +0000 (20:02 +0000)
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v95/DbVersion95.java
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v95/InsertRuleDescriptionIntoRuleDescSections.java
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptions.java [new file with mode: 0644]
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v95/InsertRuleDescriptionIntoRuleDescSectionsTest.java
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest.java [new file with mode: 0644]
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest/schema.sql [new file with mode: 0644]

index 2a3661ac40bfa23aee7462d2774c209ddf489b33..e2d0ac509b22ea10849f2963a7a5dc5eb70ca85e 100644 (file)
@@ -23,6 +23,8 @@ import org.sonar.server.platform.db.migration.step.MigrationStepRegistry;
 import org.sonar.server.platform.db.migration.version.DbVersion;
 
 public class DbVersion95 implements DbVersion {
+  static final String DEFAULT_DESCRIPTION_KEY = "default";
+
   @Override
   public void addSteps(MigrationStepRegistry registry) {
     registry
@@ -41,6 +43,8 @@ public class DbVersion95 implements DbVersion {
       .add(6412, "Add rules_metadata columns to rules table", AddRulesMetadataColumnsToRulesTable.class)
       .add(6413, "Populate rules metadata in rules table", PopulateRulesMetadataInRuleTable.class)
       .add(6414, "Drop rules_metadata table", DropRuleMetadataTable.class)
-      ;
+
+      .add(6415, "Migrate hotspot rule descriptions", MigrateHotspotRuleDescriptions.class)
+    ;
   }
 }
index 97ab88960930fe0d9084063ddc95c04b7025e3c5..8b8c486b1c8d44fa28b1523a006e53ee3d0c742c 100644 (file)
@@ -19,7 +19,6 @@
  */
 package org.sonar.server.platform.db.migration.version.v95;
 
-import com.google.common.annotations.VisibleForTesting;
 import java.sql.SQLException;
 import java.util.List;
 import org.sonar.core.util.UuidFactory;
@@ -31,8 +30,6 @@ import org.sonar.server.platform.db.migration.step.Upsert;
 import static org.sonar.server.platform.db.migration.version.v95.CreateRuleDescSectionsTable.RULE_DESCRIPTION_SECTIONS_TABLE;
 
 public class InsertRuleDescriptionIntoRuleDescSections extends DataChange {
-  @VisibleForTesting
-  static final String DEFAULT_DESCRIPTION_KEY = "default";
 
   private static final String SELECT_EXISTING_RULE_DESCRIPTIONS = "select uuid, description from rules where description is not null "
     + "and uuid not in (select rule_uuid from " + RULE_DESCRIPTION_SECTIONS_TABLE + ")";
@@ -71,7 +68,7 @@ public class InsertRuleDescriptionIntoRuleDescSections extends DataChange {
       insertRuleDescSections
         .setString(1, uuidFactory.create())
         .setString(2, ruleDb.getUuid())
-        .setString(3, DEFAULT_DESCRIPTION_KEY)
+        .setString(3, DbVersion95.DEFAULT_DESCRIPTION_KEY)
         .setString(4, ruleDb.getDescription())
         .addBatch();
     }
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptions.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptions.java
new file mode 100644 (file)
index 0000000..1ac7e55
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.platform.db.migration.version.v95;
+
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.CheckForNull;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.db.Database;
+import org.sonar.server.platform.db.migration.step.DataChange;
+import org.sonar.server.platform.db.migration.step.Upsert;
+
+import static org.sonar.api.server.rule.RuleDescriptionSection.RuleDescriptionSectionKeys.ASSESS_THE_PROBLEM_SECTION_KEY;
+import static org.sonar.api.server.rule.RuleDescriptionSection.RuleDescriptionSectionKeys.HOW_TO_FIX_SECTION_KEY;
+import static org.sonar.api.server.rule.RuleDescriptionSection.RuleDescriptionSectionKeys.ROOT_CAUSE_SECTION_KEY;
+import static org.sonar.server.platform.db.migration.version.v95.CreateRuleDescSectionsTable.RULE_DESCRIPTION_SECTIONS_TABLE;
+import static org.sonar.server.platform.db.migration.version.v95.DbVersion95.DEFAULT_DESCRIPTION_KEY;
+
+public class MigrateHotspotRuleDescriptions extends DataChange {
+
+  private static final String SELECT_DEFAULT_HOTSPOTS_DESCRIPTIONS = "select r.uuid, rds.uuid, rds.content from rules r \n"
+    + "left join " + RULE_DESCRIPTION_SECTIONS_TABLE + " rds on r.uuid = rds.rule_uuid \n"
+    + "where r.rule_type = 4 and r.template_uuid is null and rds.kee = '" + DEFAULT_DESCRIPTION_KEY + "'";
+
+  private static final String INSERT_INTO_RULE_DESC_SECTIONS = "insert into " + RULE_DESCRIPTION_SECTIONS_TABLE + " (uuid, rule_uuid, kee, content) values "
+    + "(?,?,?,?)";
+  private static final String DELETE_DEFAULT_RULE_DESC_SECTIONS = "delete from " + RULE_DESCRIPTION_SECTIONS_TABLE + " where uuid = ? ";
+
+  private final UuidFactory uuidFactory;
+
+  public MigrateHotspotRuleDescriptions(Database db, UuidFactory uuidFactory) {
+    super(db);
+    this.uuidFactory = uuidFactory;
+  }
+
+  @Override
+  protected void execute(Context context) throws SQLException {
+    List<RuleDescriptionSection> selectRuleDescriptionSection = findExistingRuleDescriptions(context);
+    if (selectRuleDescriptionSection.isEmpty()) {
+      return;
+    }
+    insertRuleDescSections(context, selectRuleDescriptionSection);
+  }
+
+  private static List<RuleDescriptionSection> findExistingRuleDescriptions(Context context) throws SQLException {
+    return context.prepareSelect(SELECT_DEFAULT_HOTSPOTS_DESCRIPTIONS)
+      .list(r -> new RuleDescriptionSection(r.getString(1), r.getString(2), r.getString(3)));
+  }
+
+  private void insertRuleDescSections(Context context, List<RuleDescriptionSection> defaultRuleDescriptionSections) throws SQLException {
+    Upsert insertRuleDescSectionsQuery = context.prepareUpsert(INSERT_INTO_RULE_DESC_SECTIONS);
+    Upsert deleteQuery = context.prepareUpsert(DELETE_DEFAULT_RULE_DESC_SECTIONS);
+    for (RuleDescriptionSection ruleDescriptionSection : defaultRuleDescriptionSections) {
+      Map<String, String> sections = generateNewNamedSections(ruleDescriptionSection.getContent());
+      if (sections.isEmpty()) {
+        continue;
+      }
+      insertNewNamedSections(insertRuleDescSectionsQuery, ruleDescriptionSection.getRuleUuid(), sections);
+      deleteOldDefaultSection(deleteQuery, ruleDescriptionSection.getSectionUuid());
+    }
+    insertRuleDescSectionsQuery.execute();
+    deleteQuery.execute().commit();
+  }
+
+  private static Map<String, String> generateNewNamedSections(String descriptionInHtml) {
+    String[] split = extractSection("", descriptionInHtml);
+    String remainingText = split[0];
+    String ruleDescriptionSection = split[1];
+
+    split = extractSection("<h2>Exceptions</h2>", remainingText);
+    remainingText = split[0];
+    String exceptions = split[1];
+
+    split = extractSection("<h2>Ask Yourself Whether</h2>", remainingText);
+    remainingText = split[0];
+    String askSection = split[1];
+
+    split = extractSection("<h2>Sensitive Code Example</h2>", remainingText);
+    remainingText = split[0];
+    String sensitiveSection = split[1];
+
+    split = extractSection("<h2>Noncompliant Code Example</h2>", remainingText);
+    remainingText = split[0];
+    String noncompliantSection = split[1];
+
+    split = extractSection("<h2>Recommended Secure Coding Practices</h2>", remainingText);
+    remainingText = split[0];
+    String recommendedSection = split[1];
+
+    split = extractSection("<h2>Compliant Solution</h2>", remainingText);
+    remainingText = split[0];
+    String compliantSection = split[1];
+
+    split = extractSection("<h2>See</h2>", remainingText);
+    remainingText = split[0];
+    String seeSection = split[1];
+
+    Map<String, String> keysToContent = new HashMap<>();
+    Optional.ofNullable(createSection(ruleDescriptionSection, exceptions, remainingText)).ifPresent(d -> keysToContent.put(ROOT_CAUSE_SECTION_KEY, d));
+    Optional.ofNullable(createSection(askSection, sensitiveSection, noncompliantSection)).ifPresent(d -> keysToContent.put(ASSESS_THE_PROBLEM_SECTION_KEY, d));
+    Optional.ofNullable(createSection(recommendedSection, compliantSection, seeSection)).ifPresent(d -> keysToContent.put(HOW_TO_FIX_SECTION_KEY, d));
+    return keysToContent;
+  }
+
+  private void insertNewNamedSections(Upsert insertRuleDescSections, String ruleUuid, Map<String, String> sections) throws SQLException {
+    for (Map.Entry<String, String> sectionKeyToContent : sections.entrySet()) {
+      insertRuleDescSections
+        .setString(1, uuidFactory.create())
+        .setString(2, ruleUuid)
+        .setString(3, sectionKeyToContent.getKey())
+        .setString(4, sectionKeyToContent.getValue())
+        .addBatch();
+    }
+  }
+
+  private static void deleteOldDefaultSection(Upsert delete, String sectionUuid) throws SQLException {
+    delete
+      .setString(1, sectionUuid)
+      .addBatch();
+  }
+
+  private static String[] extractSection(String beginning, String description) {
+    String endSection = "<h2>";
+    int beginningIndex = description.indexOf(beginning);
+    if (beginningIndex != -1) {
+      int endIndex = description.indexOf(endSection, beginningIndex + beginning.length());
+      if (endIndex == -1) {
+        endIndex = description.length();
+      }
+      return new String[] {
+        description.substring(0, beginningIndex) + description.substring(endIndex),
+        description.substring(beginningIndex, endIndex)
+      };
+    } else {
+      return new String[] {description, ""};
+    }
+
+  }
+
+  @CheckForNull
+  private static String createSection(String... contentPieces) {
+    return trimToNull(String.join("", contentPieces));
+  }
+
+  @CheckForNull
+  private static String trimToNull(String input) {
+    return input.isEmpty() ? null : input;
+  }
+
+  private static class RuleDescriptionSection {
+    private final String ruleUuid;
+    private final String sectionUuid;
+    private final String content;
+
+    private RuleDescriptionSection(String ruleUuid, String sectionUuid, String content) {
+      this.ruleUuid = ruleUuid;
+      this.sectionUuid = sectionUuid;
+      this.content = content;
+    }
+
+    public String getRuleUuid() {
+      return ruleUuid;
+    }
+
+    public String getSectionUuid() {
+      return sectionUuid;
+    }
+
+    public String getContent() {
+      return content;
+    }
+  }
+}
index 69c018e73890658ab20c7d37d19e82e32f4719d6..9dbffdad1ca776e1125d856748077e1791981e07 100644 (file)
@@ -33,7 +33,7 @@ import org.sonar.server.platform.db.migration.step.DataChange;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.sonar.server.platform.db.migration.version.v95.CreateRuleDescSectionsTable.RULE_DESCRIPTION_SECTIONS_TABLE;
-import static org.sonar.server.platform.db.migration.version.v95.InsertRuleDescriptionIntoRuleDescSections.DEFAULT_DESCRIPTION_KEY;
+import static org.sonar.server.platform.db.migration.version.v95.DbVersion95.DEFAULT_DESCRIPTION_KEY;
 
 public class InsertRuleDescriptionIntoRuleDescSectionsTest {
 
@@ -70,31 +70,31 @@ public class InsertRuleDescriptionIntoRuleDescSectionsTest {
   @Test
   public void insertRuleDescriptions_whenReentrant_doesNotFail() throws SQLException {
     String description1 = RandomStringUtils.randomAlphanumeric(5000);
-    String uuid1 = "uuid1";
-    insertRule(uuid1, description1);
+    String uuid = "uuid1";
+    insertRule(uuid, description1);
 
     insertRuleDescriptions.execute();
     insertRuleDescriptions.execute();
     insertRuleDescriptions.execute();
 
     assertThat(db.countRowsOfTable(RULE_DESCRIPTION_SECTIONS_TABLE)).isEqualTo(1);
-    assertRuleDescriptionCreated(uuid1, description1);
+    assertRuleDescriptionCreated(uuid, description1);
   }
 
   @Test
   public void insertRuleDescriptions_whenNoDescription_doesNotCreateRuleDescriptionSection() throws SQLException {
-    String uuid1 = "uuid1";
-    insertRule(uuid1, null);
+    String uuid = "uuid1";
+    insertRule(uuid, null);
 
     insertRuleDescriptions.execute();
 
     assertThat(db.countRowsOfTable(RULE_DESCRIPTION_SECTIONS_TABLE)).isZero();
   }
 
-  private void assertRuleDescriptionCreated(String uuid1, String description1) {
-    Map<String, Object> result1 = findRuleSectionDescription(uuid1);
+  private void assertRuleDescriptionCreated(String uuid, String description1) {
+    Map<String, Object> result1 = findRuleSectionDescription(uuid);
     assertThat(result1)
-      .containsEntry("RULE_UUID", uuid1)
+      .containsEntry("RULE_UUID", uuid)
       .containsEntry("KEE", DEFAULT_DESCRIPTION_KEY)
       .containsEntry("CONTENT", description1)
       .extractingByKey("UUID").isNotNull();
diff --git a/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest.java b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest.java
new file mode 100644 (file)
index 0000000..1e5d742
--- /dev/null
@@ -0,0 +1,266 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.platform.db.migration.version.v95;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import kotlin.Pair;
+import org.apache.commons.lang.RandomStringUtils;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.rules.RuleType;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.core.util.UuidFactoryFast;
+import org.sonar.db.CoreDbTester;
+import org.sonar.server.platform.db.migration.step.DataChange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.groups.Tuple.tuple;
+import static org.sonar.server.platform.db.migration.version.v95.CreateRuleDescSectionsTable.RULE_DESCRIPTION_SECTIONS_TABLE;
+import static org.sonar.server.platform.db.migration.version.v95.DbVersion95.DEFAULT_DESCRIPTION_KEY;
+
+public class MigrateHotspotRuleDescriptionsTest {
+  private static final String LEGACY_HOTSPOT_RULE_HTML_DESC = "<p>Formatted SQL queries can be difficult to maintain"
+    + "<h2>Ask Yourself Whether</h2>\n"
+    + "balbalblabla\n"
+    + "<h2>Recommended Secure Coding Practices</h2>\n"
+    + "Consider using ORM frameworks"
+    + "<h2>Sensitive Code Example</h2>\n"
+    + "mysql\n"
+    + "<h2>Compliant Solution</h2>\n"
+    + "compliant solution desc\n"
+    + "<h2>Exceptions</h2>\n"
+    + "<p>This rule current implementation does not follow variables.</p>\n"
+    + "<h2>See</h2>\n"
+    + "<a href=\"https://owasp.org/Top10/A03_2021-Injection/\">OWASP Top 10 2021 Category A3</a> - Injection </li>\n";
+
+  private static final String ISSUE_RULE = "rule_non_hotspot";
+  private static final String LEGACY_HOTSPOT_RULE = "rule_legacy_hotspot";
+  private static final String LEGACY_HOTSPOT_CUSTOM_RULE = "rule_legacy_hotspot_custom";
+  private static final String ADVANCED_RULE = "rule_advanced_hotspot";
+
+  @Rule
+  public final CoreDbTester db = CoreDbTester.createForSchema(MigrateHotspotRuleDescriptionsTest.class, "schema.sql");
+
+  private final UuidFactory uuidFactory = UuidFactoryFast.getInstance();
+
+  private final DataChange fixHotspotRuleDescriptions = new MigrateHotspotRuleDescriptions(db.database(), uuidFactory);
+
+  @Before
+  public void setUp() {
+    insertRule(ISSUE_RULE, RuleType.CODE_SMELL);
+    insertRule(LEGACY_HOTSPOT_RULE, RuleType.SECURITY_HOTSPOT);
+    insertRule(LEGACY_HOTSPOT_CUSTOM_RULE, RuleType.SECURITY_HOTSPOT, new Pair<>("template_uuid", LEGACY_HOTSPOT_RULE));
+    insertRule(ADVANCED_RULE, RuleType.SECURITY_HOTSPOT);
+  }
+
+  private void insertRule(String uuid, RuleType ruleType, Pair<String, Object> ... additionalKeyValues) {
+    Map<String, Object> ruleParams = new HashMap<>();
+    ruleParams.put("uuid", uuid);
+    ruleParams.put("plugin_rule_key", "plugin_key_" + uuid);
+    ruleParams.put("plugin_name", "plugin_name");
+    ruleParams.put("scope", "ALL");
+    ruleParams.put("is_template", false);
+    ruleParams.put("is_external", true);
+    ruleParams.put("is_ad_hoc", false);
+    ruleParams.put("rule_type", ruleType.getDbConstant());
+    Arrays.stream(additionalKeyValues).forEach(pair -> ruleParams.put(pair.getFirst(), pair.getSecond()));
+
+    db.executeInsert("rules", ruleParams);
+  }
+
+  @Test
+  public void insertRuleDescriptions_doesNotFailIfRulesDescSectionsTableIsEmpty() {
+    assertThatCode(fixHotspotRuleDescriptions::execute)
+      .doesNotThrowAnyException();
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenHtmlDescriptionIsComplete_createAllSectionsForLegacyHotspot() throws SQLException {
+    insertSectionForLegacyHotspotRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSections = findRuleDescriptionSections(LEGACY_HOTSPOT_RULE);
+
+    assertThat(ruleDescriptionSections).hasSize(3)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("root_cause", "<p>Formatted SQL queries can be difficult to maintain<h2>Exceptions</h2>\n"
+        + "<p>This rule current implementation does not follow variables.</p>\n"))
+      .contains(tuple("assess_the_problem", "<h2>Ask Yourself Whether</h2>\n"
+        + "balbalblabla\n"
+        + "<h2>Sensitive Code Example</h2>\n"
+        + "mysql\n"))
+      .contains(tuple("how_to_fix", "<h2>Recommended Secure Coding Practices</h2>\n"
+        + "Consider using ORM frameworks<h2>Compliant Solution</h2>\n"
+        + "compliant solution desc\n"
+        + "<h2>See</h2>\n"
+        + "<a href=\"https://owasp.org/Top10/A03_2021-Injection/\">OWASP Top 10 2021 Category A3</a> - Injection </li>\n"));
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenCustomRule_doNotCreateSections() throws SQLException {
+    insertSectionForLegacyCustomHotspotRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSections = findRuleDescriptionSections(LEGACY_HOTSPOT_CUSTOM_RULE);
+
+    assertThat(ruleDescriptionSections)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .containsOnly(tuple(DEFAULT_DESCRIPTION_KEY, LEGACY_HOTSPOT_RULE_HTML_DESC));
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenMixtureOfRules_createAllSectionsForLegacyHotspotAndDoNotModifyOthers() throws SQLException {
+    insertSectionForLegacyIssueRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+    insertSectionForLegacyHotspotRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+    insertSectionForAdvancedRule("test", LEGACY_HOTSPOT_RULE_HTML_DESC);
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSectionsLegacyHotspotRule = findRuleDescriptionSections(LEGACY_HOTSPOT_RULE);
+    List<Map<String, Object>> ruleDescriptionSectionsIssueRule = findRuleDescriptionSections(ISSUE_RULE);
+    List<Map<String, Object>> ruleDescriptionSectionsAdvancedRule = findRuleDescriptionSections(ADVANCED_RULE);
+
+    assertThat(ruleDescriptionSectionsIssueRule)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .containsOnly(tuple(DEFAULT_DESCRIPTION_KEY, LEGACY_HOTSPOT_RULE_HTML_DESC));
+
+    assertThat(ruleDescriptionSectionsAdvancedRule)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("test", LEGACY_HOTSPOT_RULE_HTML_DESC));
+
+    assertThat(ruleDescriptionSectionsLegacyHotspotRule).hasSize(3)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("root_cause", "<p>Formatted SQL queries can be difficult to maintain<h2>Exceptions</h2>\n"
+        + "<p>This rule current implementation does not follow variables.</p>\n"))
+      .contains(tuple("assess_the_problem", "<h2>Ask Yourself Whether</h2>\n"
+        + "balbalblabla\n"
+        + "<h2>Sensitive Code Example</h2>\n"
+        + "mysql\n"))
+      .contains(tuple("how_to_fix", "<h2>Recommended Secure Coding Practices</h2>\n"
+        + "Consider using ORM frameworks<h2>Compliant Solution</h2>\n"
+        + "compliant solution desc\n"
+        + "<h2>See</h2>\n"
+        + "<a href=\"https://owasp.org/Top10/A03_2021-Injection/\">OWASP Top 10 2021 Category A3</a> - Injection </li>\n"));
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenHtmlDescriptionContainsNoHeaders_createOnlyRootCause() throws SQLException {
+    String noSection = "No sections";
+    insertSectionForLegacyHotspotRule(noSection);
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSections = findRuleDescriptionSections(LEGACY_HOTSPOT_RULE);
+
+    assertThat(ruleDescriptionSections)
+      .hasSize(1)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("root_cause", noSection));
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenLegacyIssueRule_doNotChangeSections() throws SQLException {
+    insertSectionForLegacyIssueRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSections = findRuleDescriptionSections(ISSUE_RULE);
+
+    assertThat(ruleDescriptionSections)
+      .hasSize(1)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("default", LEGACY_HOTSPOT_RULE_HTML_DESC));
+  }
+
+  @Test
+  public void fixHotspotRuleDescriptions_whenAdvancedRule_doNotChangeSections() throws SQLException {
+    insertSectionForAdvancedRule("root_cause", LEGACY_HOTSPOT_RULE_HTML_DESC);
+    insertSectionForAdvancedRule("assess_the_problem", LEGACY_HOTSPOT_RULE_HTML_DESC);
+    insertSectionForAdvancedRule("how_to_fix", LEGACY_HOTSPOT_RULE_HTML_DESC);
+    insertSectionForAdvancedRule("other", "blablabla");
+
+    fixHotspotRuleDescriptions.execute();
+
+    List<Map<String, Object>> ruleDescriptionSections = findRuleDescriptionSections(ADVANCED_RULE);
+
+    assertThat(ruleDescriptionSections)
+      .hasSize(4)
+      .extracting(r -> r.get("KEE"), r -> r.get("CONTENT"))
+      .contains(tuple("root_cause", LEGACY_HOTSPOT_RULE_HTML_DESC))
+      .contains(tuple("assess_the_problem", LEGACY_HOTSPOT_RULE_HTML_DESC))
+      .contains(tuple("how_to_fix", LEGACY_HOTSPOT_RULE_HTML_DESC))
+      .contains(tuple("other", "blablabla"));
+  }
+
+  @Test
+  public void insertRuleDescriptions_whenReentrant_doesNotFail() throws SQLException {
+    insertSectionForLegacyHotspotRule(LEGACY_HOTSPOT_RULE_HTML_DESC);
+
+    fixHotspotRuleDescriptions.execute();
+    fixHotspotRuleDescriptions.execute();
+    fixHotspotRuleDescriptions.execute();
+
+    assertThat(findRuleDescriptionSections(LEGACY_HOTSPOT_RULE)).hasSize(3);
+  }
+
+  private List<Map<String, Object>> findAllRuleDescriptionSections() {
+    return db.select("select uuid, kee, rule_uuid, content from "
+      + RULE_DESCRIPTION_SECTIONS_TABLE + "'");
+  }
+
+  private List<Map<String, Object>> findRuleDescriptionSections(String ruleUuid) {
+    return db.select("select uuid, kee, rule_uuid, content from "
+      + RULE_DESCRIPTION_SECTIONS_TABLE + " where rule_uuid = '" + ruleUuid + "'");
+  }
+
+  private void insertSectionForLegacyHotspotRule(String content) {
+    insertRuleDescriptionSection(LEGACY_HOTSPOT_RULE, DEFAULT_DESCRIPTION_KEY, content);
+  }
+
+  private void insertSectionForLegacyCustomHotspotRule(String content) {
+    insertRuleDescriptionSection(LEGACY_HOTSPOT_CUSTOM_RULE, DEFAULT_DESCRIPTION_KEY, content);
+  }
+
+  private void insertSectionForLegacyIssueRule(String content) {
+    insertRuleDescriptionSection(ISSUE_RULE, DEFAULT_DESCRIPTION_KEY, content);
+  }
+
+  private void insertSectionForAdvancedRule(String key, String content) {
+    insertRuleDescriptionSection(ADVANCED_RULE, key, content);
+  }
+
+  private void insertRuleDescriptionSection(String ruleUuid, String key, String content) {
+    Map<String, Object> ruleParams = new HashMap<>();
+    ruleParams.put("uuid", RandomStringUtils.randomAlphanumeric(20));
+    ruleParams.put("rule_uuid", ruleUuid);
+    ruleParams.put("kee", key);
+    ruleParams.put("content", content);
+
+    db.executeInsert(RULE_DESCRIPTION_SECTIONS_TABLE, ruleParams);
+  }
+}
diff --git a/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest/schema.sql b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v95/MigrateHotspotRuleDescriptionsTest/schema.sql
new file mode 100644 (file)
index 0000000..de8a007
--- /dev/null
@@ -0,0 +1,35 @@
+CREATE TABLE "RULE_DESC_SECTIONS"(
+    "UUID" CHARACTER VARYING(40) NOT NULL,
+    "RULE_UUID" CHARACTER VARYING(40) NOT NULL,
+    "KEE" CHARACTER VARYING(50) NOT NULL,
+    "CONTENT" CHARACTER LARGE OBJECT NOT NULL
+);
+ALTER TABLE "RULE_DESC_SECTIONS" ADD CONSTRAINT "PK_RULE_DESC_SECTIONS" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "UNIQ_RULE_DESC_SECTIONS_KEE" ON "RULE_DESC_SECTIONS"("RULE_UUID" NULLS FIRST, "KEE" NULLS FIRST);
+
+CREATE TABLE "RULES"(
+    "UUID" CHARACTER VARYING(40) NOT NULL,
+    "NAME" CHARACTER VARYING(200),
+    "PLUGIN_RULE_KEY" CHARACTER VARYING(200) NOT NULL,
+    "PLUGIN_KEY" CHARACTER VARYING(200),
+    "PLUGIN_CONFIG_KEY" CHARACTER VARYING(200),
+    "PLUGIN_NAME" CHARACTER VARYING(255) NOT NULL,
+    "SCOPE" CHARACTER VARYING(20) NOT NULL,
+    "PRIORITY" INTEGER,
+    "STATUS" CHARACTER VARYING(40),
+    "LANGUAGE" CHARACTER VARYING(20),
+    "DEF_REMEDIATION_FUNCTION" CHARACTER VARYING(20),
+    "DEF_REMEDIATION_GAP_MULT" CHARACTER VARYING(20),
+    "DEF_REMEDIATION_BASE_EFFORT" CHARACTER VARYING(20),
+    "GAP_DESCRIPTION" CHARACTER VARYING(4000),
+    "SYSTEM_TAGS" CHARACTER VARYING(4000),
+    "IS_TEMPLATE" BOOLEAN DEFAULT FALSE NOT NULL,
+    "DESCRIPTION_FORMAT" CHARACTER VARYING(20),
+    "RULE_TYPE" TINYINT,
+    "SECURITY_STANDARDS" CHARACTER VARYING(4000),
+    "IS_AD_HOC" BOOLEAN NOT NULL,
+    "IS_EXTERNAL" BOOLEAN NOT NULL,
+    "TEMPLATE_UUID" CHARACTER VARYING(40)
+);
+ALTER TABLE "RULES" ADD CONSTRAINT "PK_RULES" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "RULES_REPO_KEY" ON "RULES"("PLUGIN_RULE_KEY" NULLS FIRST, "PLUGIN_NAME" NULLS FIRST);