From 30e0ba1d8691bafd28ac5dbe505f581cad3d2d76 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 15 Oct 2025 11:59:49 +0200 Subject: [PATCH] add migrationtest sql-dump-checksum-assertions to prevent accidental changes (#204) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/204 Reviewed-by: Timotheus Pokorra --- .../hsadminng/hs/migration/CsvDataImport.java | 33 +-------- .../hs/migration/ImportHostingAssets.java | 18 ++++- ...LiquibaseCompatibilityIntegrationTest.java | 17 ++++- .../hsadminng/hs/migration/ResourceUtil.java | 67 +++++++++++++++++++ .../released-prod-schema-with-test-data.sql | 2 +- 5 files changed, 102 insertions(+), 35 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/migration/ResourceUtil.java diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java index 2226a924..8b297204 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java @@ -7,39 +7,29 @@ import lombok.SneakyThrows; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; -import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.persistence.BaseEntity; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.TestWatcher; -import org.opentest4j.AssertionFailedError; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.AbstractResource; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.FileSystemResource; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.core.io.Resource; import org.springframework.transaction.support.TransactionTemplate; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ValidationException; -import jakarta.validation.constraints.NotNull; import java.io.BufferedReader; -import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; import java.io.Reader; import java.io.StringReader; import java.io.StringWriter; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.time.LocalDate; import java.util.LinkedHashSet; import java.util.List; @@ -50,7 +40,6 @@ import java.util.stream.Collectors; import static java.lang.Boolean.parseBoolean; import static java.util.Arrays.stream; -import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; import static net.hostsharing.hsadminng.mapper.Array.emptyArray; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -122,26 +111,6 @@ public class CsvDataImport extends ContextBasedTest { return stream(lines.getFirst()).map(String::trim).toArray(String[]::new); } - public static @NotNull AbstractResource resourceOf(final String sqlFile) { - return new File(sqlFile).exists() - ? new FileSystemResource(sqlFile) - : new ClassPathResource(sqlFile); - } - - protected Reader resourceReader(@NotNull final String resourcePath) { - try { - return new InputStreamReader(requireNonNull(resourceOf(resourcePath).getInputStream())); - } catch (final Exception exc) { - throw new AssertionFailedError("cannot open '" + resourcePath + "'"); - } - } - - @SneakyThrows - protected String resourceAsString(final Resource resource) { - final var lines = Files.readAllLines(resource.getFile().toPath(), StandardCharsets.UTF_8); - return String.join("\n", lines); - } - protected List withoutHeader(final List records) { return records.subList(1, records.size()); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java index 40151462..fe61fb55 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java @@ -3,7 +3,6 @@ package net.hostsharing.hsadminng.hs.migration; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; -import net.hostsharing.hsadminng.rbac.context.Context; import net.hostsharing.hsadminng.hash.HashGenerator; import net.hostsharing.hsadminng.hash.HashGenerator.Algorithm; import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; @@ -19,6 +18,7 @@ import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator; +import net.hostsharing.hsadminng.rbac.context.Context; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.collections4.ListUtils; import org.jetbrains.annotations.NotNull; @@ -84,6 +84,9 @@ import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQ import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_INSTANCE; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.hs.migration.ResourceUtil.resourceAsString; +import static net.hostsharing.hsadminng.hs.migration.ResourceUtil.resourceOf; +import static net.hostsharing.hsadminng.hs.migration.ResourceUtil.resourceReader; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assumptions.assumeThat; @@ -143,6 +146,18 @@ public class ImportHostingAssets extends CsvDataImport { @Value("${HSADMINNG_OFFICE_DATA_SQL_FILE:/db/released-prod-schema-with-import-test-data.sql}") String officeSchemaAndDataSqlFile; + @Test + @Order(10000) + @SneakyThrows + void baseSchemaDumpIsUnchanged() { + ResourceUtil.assertResourceHash( + officeSchemaAndDataSqlFile, + // Never change this hash! + // Except if you deliberately updated the reference SQL dump, e.g. after a prod release. + // It protects you from accidentally making changes, e.g. with search&replace. + "41e6e3ad7f2ba5de507219582d76fd33ebc93bef148f2ee2c25ae9b5b8c0f53b"); + } + @Test @Order(11000) @SneakyThrows @@ -1961,6 +1976,7 @@ public class ImportHostingAssets extends CsvDataImport { .collect(joining("\n")); } + @SneakyThrows private void executeSqlScript(final String sqlFile) { jpaAttempt.transacted(() -> { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/LiquibaseCompatibilityIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/LiquibaseCompatibilityIntegrationTest.java index e97594bd..b348bbbb 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/LiquibaseCompatibilityIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/LiquibaseCompatibilityIntegrationTest.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.hs.migration; +import lombok.SneakyThrows; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -43,9 +44,12 @@ import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TE @DirtiesContext @ActiveProfiles("liquibase-migration-test") @Import(LiquibaseConfig.class) -@Sql(value = "/db/released-prod-schema-with-test-data.sql", executionPhase = BEFORE_TEST_CLASS) // release-schema +@Sql(value = + LiquibaseCompatibilityIntegrationTest.DB_RELEASED_PROD_SCHEMA_RESOURCE_WITH_TEST_DATA_SQL, + executionPhase = BEFORE_TEST_CLASS) public class LiquibaseCompatibilityIntegrationTest { + public static final String DB_RELEASED_PROD_SCHEMA_RESOURCE_WITH_TEST_DATA_SQL = "/db/released-prod-schema-with-test-data.sql"; private static final String EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION = "hs-global-liquibase-migration-test"; private static final int EXPECTED_LIQUIBASE_CHANGELOGS_IN_PROD_SCHEMA_DUMP = 299; @@ -55,6 +59,17 @@ public class LiquibaseCompatibilityIntegrationTest { @Autowired private LiquibaseMigration liquibase; + @Test + @SneakyThrows + void baseSchemaDumpIsUnchanged() { + ResourceUtil.assertResourceHash( + DB_RELEASED_PROD_SCHEMA_RESOURCE_WITH_TEST_DATA_SQL, + // Never change this hash! + // Except, if you deliberately updated the reference SQL dump, e.g. after a prod release. + // It protects you from accidentally making changes, e.g. with search&replace. + "512216e4baeed3f5d988766ff79a79c2885c8c04fa54c61009bde3d4f4708d72"); + } + @Test void migrationWorksBasedOnAPreviouslyPopulatedSchema() { // check the initial status from the @Sql-annotation diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ResourceUtil.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ResourceUtil.java new file mode 100644 index 00000000..ea3cfc50 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ResourceUtil.java @@ -0,0 +1,67 @@ +package net.hostsharing.hsadminng.hs.migration; + +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.val; +import org.opentest4j.AssertionFailedError; +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; + +import jakarta.validation.constraints.NotNull; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.util.FileCopyUtils.copyToByteArray; + +@UtilityClass +public class ResourceUtil { + + public void assertResourceHash(final String givenResourceOrFileName, final String expectedHash) throws IOException, + NoSuchAlgorithmException { + try (val inputStream = resourceOf(givenResourceOrFileName).getInputStream()) { + val fileContent = copyToByteArray(inputStream); + val digest = MessageDigest.getInstance("SHA-256"); + val hashBytes = digest.digest(fileContent); + val hashHex = bytesToHex(hashBytes); + assertThat(hashHex).isEqualTo(expectedHash); + } + } + + private static String bytesToHex(byte[] bytes) { + val result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + public static @NotNull AbstractResource resourceOf(final String sqlFile) { + return new File(sqlFile).exists() + ? new FileSystemResource(sqlFile) + : new ClassPathResource(sqlFile); + } + + public static Reader resourceReader(@NotNull final String resourcePath) { + try { + return new InputStreamReader(requireNonNull(resourceOf(resourcePath).getInputStream())); + } catch (final Exception exc) { + throw new AssertionFailedError("cannot open '" + resourcePath + "'"); + } + } + + @SneakyThrows + public static String resourceAsString(final Resource resource) { + final var lines = Files.readAllLines(resource.getFile().toPath(), StandardCharsets.UTF_8); + return String.join("\n", lines); + } +} diff --git a/src/test/resources/db/released-prod-schema-with-test-data.sql b/src/test/resources/db/released-prod-schema-with-test-data.sql index 80d1fbd4..943c6772 100644 --- a/src/test/resources/db/released-prod-schema-with-test-data.sql +++ b/src/test/resources/db/released-prod-schema-with-test-data.sql @@ -12750,7 +12750,7 @@ INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, ordere INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('rbactest-domain-rbac-update-trigger', 'RolesGrantsAndPermissionsGenerator', 'db/changelog/2-rbactest/203-rbactest-domain/2033-rbactest-domain-rbac.sql', '2025-06-06 10:23:06.53273', 309, 'RERAN', '9:4d2cdd1bc08df6985622d0c5953bf4ba', 'sql', '', NULL, '4.29.2', '!without-test-data', NULL, '9198184942'); INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-contact-rbac-insert-trigger', 'RolesGrantsAndPermissionsGenerator', 'db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.sql', '2025-06-06 10:23:06.581665', 310, 'RERAN', '9:b29cf6087a2225d235637a187b5946c2', 'sql', '', NULL, '4.29.2', NULL, NULL, '9198184942'); INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-person-rbac-insert-trigger', 'RolesGrantsAndPermissionsGenerator', 'db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.sql', '2025-06-06 10:23:06.611557', 311, 'RERAN', '9:9adfbff372bce3de87410eb7582869f2', 'sql', '', NULL, '4.29.2', NULL, NULL, '9198184942'); -INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-person-TEST-DATA-GENERATION-FOR-CREDENTIALS', 'michael.hoennig', 'db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-accounts.sql', '2025-06-06 10:23:06.727793', 312, 'EXECUTED', '9:cf05c4705d539d4ea7b0199921f8cc78', 'sql', '', NULL, '4.29.2', '!without-test-data AND !without-test-data', NULL, '9198184942'); +INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-person-TEST-DATA-GENERATION-FOR-CREDENTIALS', 'michael.hoennig', 'db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql', '2025-06-06 10:23:06.727793', 312, 'EXECUTED', '9:cf05c4705d539d4ea7b0199921f8cc78', 'sql', '', NULL, '4.29.2', '!without-test-data AND !without-test-data', NULL, '9198184942'); INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-relation-debitor-anchor-CONSTRAINT-BY-TRIGGER', 'marc.sandlus', 'db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql', '2025-06-06 10:23:06.759158', 313, 'EXECUTED', '9:0623a018f7494b53f87b01d88bc3fa41', 'sql', '', NULL, '4.29.2', NULL, NULL, '9198184942'); INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-relation-rbac-insert-trigger', 'RolesGrantsAndPermissionsGenerator', 'db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql', '2025-06-06 10:23:06.7945', 314, 'RERAN', '9:6fc1a046dcd803edfb0ce77cd127b576', 'sql', '', NULL, '4.29.2', NULL, NULL, '9198184942'); INSERT INTO public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) VALUES ('hs-office-relation-rbac-update-trigger', 'RolesGrantsAndPermissionsGenerator', 'db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql', '2025-06-06 10:23:06.835025', 315, 'RERAN', '9:6737dfa739a4b9036f3e9e8224019276', 'sql', '', NULL, '4.29.2', NULL, NULL, '9198184942');