1
0

add migrationtest sql-dump-checksum-assertions to prevent accidental changes (#204)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/204
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-10-15 11:59:49 +02:00
parent 4994341232
commit 30e0ba1d86
5 changed files with 102 additions and 35 deletions
@@ -7,39 +7,29 @@ import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject; import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; 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.persistence.BaseEntity;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher; import org.junit.jupiter.api.extension.TestWatcher;
import org.opentest4j.AssertionFailedError;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; 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.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.io.Resource;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
import jakarta.validation.constraints.NotNull;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader; import java.io.Reader;
import java.io.StringReader; import java.io.StringReader;
import java.io.StringWriter; import java.io.StringWriter;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -50,7 +40,6 @@ import java.util.stream.Collectors;
import static java.lang.Boolean.parseBoolean; import static java.lang.Boolean.parseBoolean;
import static java.util.Arrays.stream; import static java.util.Arrays.stream;
import static java.util.Objects.requireNonNull;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.mapper.Array.emptyArray; import static net.hostsharing.hsadminng.mapper.Array.emptyArray;
import static org.apache.commons.lang3.StringUtils.isNotBlank; 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); 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<String[]> withoutHeader(final List<String[]> records) { protected List<String[]> withoutHeader(final List<String[]> records) {
return records.subList(1, records.size()); return records.subList(1, records.size());
} }
@@ -3,7 +3,6 @@ package net.hostsharing.hsadminng.hs.migration;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.hash.HashGenerator; import net.hostsharing.hsadminng.hash.HashGenerator;
import net.hostsharing.hsadminng.hash.HashGenerator.Algorithm; import net.hostsharing.hsadminng.hash.HashGenerator.Algorithm;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; 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.HostingAssetEntitySaveProcessor;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.apache.commons.collections4.ListUtils; import org.apache.commons.collections4.ListUtils;
import org.jetbrains.annotations.NotNull; 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_INSTANCE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER; 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.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 net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assumptions.assumeThat; 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}") @Value("${HSADMINNG_OFFICE_DATA_SQL_FILE:/db/released-prod-schema-with-import-test-data.sql}")
String officeSchemaAndDataSqlFile; 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 @Test
@Order(11000) @Order(11000)
@SneakyThrows @SneakyThrows
@@ -1961,6 +1976,7 @@ public class ImportHostingAssets extends CsvDataImport {
.collect(joining("\n")); .collect(joining("\n"));
} }
@SneakyThrows @SneakyThrows
private void executeSqlScript(final String sqlFile) { private void executeSqlScript(final String sqlFile) {
jpaAttempt.transacted(() -> { jpaAttempt.transacted(() -> {
@@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.hs.migration; package net.hostsharing.hsadminng.hs.migration;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -43,9 +44,12 @@ import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TE
@DirtiesContext @DirtiesContext
@ActiveProfiles("liquibase-migration-test") @ActiveProfiles("liquibase-migration-test")
@Import(LiquibaseConfig.class) @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 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 String EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION = "hs-global-liquibase-migration-test";
private static final int EXPECTED_LIQUIBASE_CHANGELOGS_IN_PROD_SCHEMA_DUMP = 299; private static final int EXPECTED_LIQUIBASE_CHANGELOGS_IN_PROD_SCHEMA_DUMP = 299;
@@ -55,6 +59,17 @@ public class LiquibaseCompatibilityIntegrationTest {
@Autowired @Autowired
private LiquibaseMigration liquibase; 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 @Test
void migrationWorksBasedOnAPreviouslyPopulatedSchema() { void migrationWorksBasedOnAPreviouslyPopulatedSchema() {
// check the initial status from the @Sql-annotation // check the initial status from the @Sql-annotation
@@ -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);
}
}
@@ -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 ('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-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-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-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-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'); 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');