Compare commits

...

3 Commits

Author SHA1 Message Date
16252334b7 programmatically generate liquibase-compare-file for prod-release 2025-01-28 (#153)
Co-authored-by: Timotheus Pokorra <timotheus.pokorra@solidcharity.com>
Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #153
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
2025-01-30 09:36:32 +01:00
ce7e3741bd feature/test-liquibase-migration-from-a-prod-dump (#152)
Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #152
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
2025-01-28 12:27:54 +01:00
2a61686918 use-latest-versions and improved test-code-coverage (#151)
Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #151
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
2025-01-24 09:28:52 +01:00
14 changed files with 17460 additions and 41 deletions

View File

@ -523,7 +523,7 @@ Classes to be scanned, tests to be executed and thresholds are configured in [bu
A report is generated under [build/reports/pitest/index.html](./build/reports/pitest/index.html). A report is generated under [build/reports/pitest/index.html](./build/reports/pitest/index.html).
A link to the report is also printed after the `pitest` run. A link to the report is also printed after the `pitest` run.
This task is also executed as part of `gw check`. <!-- TODO.test: This task is also executed as part of `gw check`. -->
#### Remark #### Remark
@ -562,7 +562,7 @@ In case of suppression, a note must be added to explain why it does not apply to
See also: https://jeremylong.github.io/DependencyCheck/dependency-check-gradle/index.html. See also: https://jeremylong.github.io/DependencyCheck/dependency-check-gradle/index.html.
### Dependency-License-Compatibility ### How to Check Dependency-License-Compatibility
The `gw check` phase depends on a dependency-license-compatibility check. The `gw check` phase depends on a dependency-license-compatibility check.
If any dependency violates the configured [list of allowed licenses](etc/allowed-licenses.json), the build will fail. If any dependency violates the configured [list of allowed licenses](etc/allowed-licenses.json), the build will fail.
@ -592,7 +592,7 @@ The generated license can be found here: [index.html](build/reports/dependency-l
More information can be found on the [project's website](https://github.com/jk1/Gradle-License-Report). More information can be found on the [project's website](https://github.com/jk1/Gradle-License-Report).
### Dependency Version Upgrade ### How to Upgrade Versions of Dependencies
Dependency versions can be automatically upgraded to the latest available version: Dependency versions can be automatically upgraded to the latest available version:
@ -620,8 +620,8 @@ This way we would get rid of all explicit grants within the same DB-row
and would not need the `rbac.role` table anymore. and would not need the `rbac.role` table anymore.
We would also reduce the depth of the expensive recursive CTE-query. We would also reduce the depth of the expensive recursive CTE-query.
This has to be explored further. This has to be explored further. For now, we just keep it in mind and avoid roles+grants
For now, we just keep it in mind and FIXME which would not fit into a simplified system with a fixed role-type-system.
### The Mapper is Error-Prone ### The Mapper is Error-Prone

View File

@ -1,15 +1,15 @@
plugins { plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '3.4.1' id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7' // manages implicit dependencies
id 'io.openapiprocessor.openapi-processor' version '2023.2' id 'io.openapiprocessor.openapi-processor' version '2023.2' // generates Controller-interface and resources from API-spec
id 'com.github.jk1.dependency-license-report' version '2.9' id 'com.github.jk1.dependency-license-report' version '2.9' // checks dependency-license compatibility
id "org.owasp.dependencycheck" version "12.0.0" id "org.owasp.dependencycheck" version "12.0.1" // checks dependencies for known vulnerabilities
id "com.diffplug.spotless" version "7.0.2" id "com.diffplug.spotless" version "7.0.2" // formats + checks formatting for source-code
id 'jacoco' id 'jacoco' // determines code-coverage of tests
id 'info.solidsoft.pitest' version '1.15.0' id 'info.solidsoft.pitest' version '1.15.0' // performs mutation testing
id 'se.patrikerdes.use-latest-versions' version '0.2.18' id 'se.patrikerdes.use-latest-versions' version '0.2.18' // updates module and plugin versions
id 'com.github.ben-manes.versions' version '0.51.0' id 'com.github.ben-manes.versions' version '0.52.0' // determines which dependencies have updates
} }
// HOWTO: find out which dependency versions are managed by Spring Boot: // HOWTO: find out which dependency versions are managed by Spring Boot:
@ -227,7 +227,7 @@ project.tasks.check.dependsOn(spotlessCheck)
// HACK: no idea why spotless uses the output of these tasks, but we get warnings without those // HACK: no idea why spotless uses the output of these tasks, but we get warnings without those
project.tasks.spotlessJava.dependsOn( project.tasks.spotlessJava.dependsOn(
tasks.generateLicenseReport, tasks.generateLicenseReport,
tasks.pitest, // tasks.pitest, TODO.test: PiTest currently does not work, needs to be fixed
tasks.jacocoTestReport, tasks.jacocoTestReport,
tasks.processResources, tasks.processResources,
tasks.processTestResources) tasks.processTestResources)
@ -335,7 +335,7 @@ jacocoTestCoverageVerification {
} }
} }
// HOWTO: run all unit-tests which don't need a database: gw unitTest // HOWTO: run all unit-tests which don't need a database: gw-test unitTest
tasks.register('unitTest', Test) { tasks.register('unitTest', Test) {
useJUnitPlatform { useJUnitPlatform {
excludeTags 'importOfficeData', 'importHostingAssets', 'scenarioTest', 'generalIntegrationTest', excludeTags 'importOfficeData', 'importHostingAssets', 'scenarioTest', 'generalIntegrationTest',
@ -360,7 +360,7 @@ tasks.register('generalIntegrationTest', Test) {
mustRunAfter spotlessJava mustRunAfter spotlessJava
} }
// HOWTO: run all integration tests of the office module: gw officeIntegrationTest // HOWTO: run all integration tests of the office module: gw-test officeIntegrationTest
tasks.register('officeIntegrationTest', Test) { tasks.register('officeIntegrationTest', Test) {
useJUnitPlatform { useJUnitPlatform {
includeTags 'officeIntegrationTest' includeTags 'officeIntegrationTest'
@ -372,26 +372,26 @@ tasks.register('officeIntegrationTest', Test) {
mustRunAfter spotlessJava mustRunAfter spotlessJava
} }
// HOWTO: run all integration tests of the booking module: gw bookingIntegrationTest // HOWTO: run all integration tests of the booking module: gw-test bookingIntegrationTest
tasks.register('bookingIntegrationTest', Test) { tasks.register('bookingIntegrationTest', Test) {
useJUnitPlatform { useJUnitPlatform {
includeTags 'bookingIntegrationTest' includeTags 'bookingIntegrationTest'
} }
group 'verification' group 'verification'
description 'runs integration tests of the office module' description 'runs integration tests of the booking module'
mustRunAfter spotlessJava mustRunAfter spotlessJava
} }
// HOWTO: run all integration tests of the hosting module: gw hostingIntegrationTest // HOWTO: run all integration tests of the hosting module: gw-test hostingIntegrationTest
tasks.register('hostingIntegrationTest', Test) { tasks.register('hostingIntegrationTest', Test) {
useJUnitPlatform { useJUnitPlatform {
includeTags 'hostingIntegrationTest' includeTags 'hostingIntegrationTest'
} }
group 'verification' group 'verification'
description 'runs integration tests of the office module' description 'runs integration tests of the hosting module'
mustRunAfter spotlessJava mustRunAfter spotlessJava
} }
@ -454,7 +454,7 @@ pitest {
outputFormats = ['XML', 'HTML'] outputFormats = ['XML', 'HTML']
timestampedReports = false timestampedReports = false
} }
project.tasks.check.dependsOn(project.tasks.pitest) // project.tasks.check.dependsOn(project.tasks.pitest) TODO.test: PiTest currently does not work, needs to be fixed
project.tasks.pitest.doFirst { // Why not doLast? See README.md! project.tasks.pitest.doFirst { // Why not doLast? See README.md!
println "PiTest Mutation Report: file:///${project.rootDir}/build/reports/pitest/index.html" println "PiTest Mutation Report: file:///${project.rootDir}/build/reports/pitest/index.html"
} }

View File

@ -1,9 +1,10 @@
--liquibase formatted sql --liquibase formatted sql
-- FIXME: check if we really need the restricted user
-- ============================================================================ -- ============================================================================
-- NUMERIC-HASH-FUNCTIONS -- NUMERIC-HASH-FUNCTIONS
--changeset michael.hoennig:hash endDelimiter:--// --changeset michael.hoennig:hash runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
do $$ do $$

View File

@ -870,18 +870,23 @@ $$;
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:rbac-base-PGSQL-ROLES context:!external-db endDelimiter:--// --changeset michael.hoennig:rbac-base-PGSQL-ROLES runOnChange:true validCheckSum:ANY context:!external-db endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
do $$ do $$
begin begin
if '${HSADMINNG_POSTGRES_ADMIN_USERNAME}'='admin' then if '${HSADMINNG_POSTGRES_ADMIN_USERNAME}'='admin' then
create role admin; if not exists (select from pg_catalog.pg_roles where rolname = 'admin') then
create role admin;
end if;
grant all privileges on all tables in schema public to admin; grant all privileges on all tables in schema public to admin;
end if; end if;
if '${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}'='restricted' then if '${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}'='restricted' then
create role restricted; if not exists (select from pg_catalog.pg_roles where rolname = 'restricted') then
create role restricted;
end if;
grant all privileges on all tables in schema public to restricted; grant all privileges on all tables in schema public to restricted;
end if; end if;
end $$; end $$;

View File

@ -2,7 +2,7 @@
--liquibase formatted sql --liquibase formatted sql
-- ============================================================================ -- ============================================================================
--changeset timotheus.pokorra:hs-global-integration-znuny endDelimiter:--// --changeset timotheus.pokorra:hs-global-integration-mlmmj endDelimiter:--//
CREATE OR REPLACE VIEW hs_integration.subscription AS CREATE OR REPLACE VIEW hs_integration.subscription AS
SELECT DISTINCT SELECT DISTINCT
relation.mark as subscription, relation.mark as subscription,

View File

@ -0,0 +1,14 @@
--liquibase formatted sql
-- ============================================================================
--changeset michael.hoennig:hs-global-liquibase-migration-test endDelimiter:--//
CREATE OR REPLACE VIEW hs_integration.subscription AS
SELECT DISTINCT
relation.mark as subscription,
contact.emailaddresses->>'main' as email
FROM hs_office.contact AS contact
JOIN hs_office.relation AS relation ON relation.contactuuid = contact.uuid AND relation.type = 'SUBSCRIBER'
ORDER BY subscription, email;
--//

View File

@ -220,3 +220,7 @@ databaseChangeLog:
file: db/changelog/9-hs-global/9120-integration-znuny.sql file: db/changelog/9-hs-global/9120-integration-znuny.sql
- include: - include:
file: db/changelog/9-hs-global/9130-integration-mlmmj.sql file: db/changelog/9-hs-global/9130-integration-mlmmj.sql
- include:
file: db/changelog/9-hs-global/9999-liquibase-migration-test.sql
context: liquibase-migration-test

View File

@ -17,6 +17,7 @@ import static net.hostsharing.hsadminng.config.HttpHeadersBuilder.headers;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.client.WireMock.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {"server.port=0", "hsadminng.cas.server=http://localhost:8088/cas"}) @TestPropertySource(properties = {"server.port=0", "hsadminng.cas.server=http://localhost:8088/cas"})
@ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile! @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!

View File

@ -72,8 +72,8 @@ class HashGeneratorUnitTest {
@Test @Test
void generatesMySqlNativePasswordHash() { void generatesMySqlNativePasswordHash() {
final var hash = HashGenerator.using(MYSQL_NATIVE).hash("Test1234"); final var hash = HashGenerator.using(MYSQL_NATIVE).hash("t8L7FULt"); // results in line+branch-coverage
assertThat(hash).isEqualTo("*14F1A8C42F8B6D4662BB3ED290FD37BF135FE45C"); assertThat(hash).isEqualTo("*F1E107E5C47E0939C7BC941DDE59EDBBDA1F7E39");
} }
@Test @Test

View File

@ -56,6 +56,35 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
"{type=string[], propertyName=target, elementsOf={type=string, propertyName=target, matchesRegEx=[^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$, ^/dev/null$], maxLength=320}, required=true, minLength=1}"); "{type=string[], propertyName=target, elementsOf={type=string, propertyName=target, matchesRegEx=[^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$, ^/dev/null$], maxLength=320}, required=true, minLength=1}");
} }
@Test
void preprocessEntityWithInitializedIdentifier() {
// given
final var givenEntity = validEntityBuilder().identifier("some-local-part@example.org").build();
assertThat(givenEntity.getParentAsset().getIdentifier()).as("preconditon failed").isEqualTo("example.org|MBOX");
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when
validator.preprocessEntity(givenEntity);
// then
assertThat(givenEntity.getIdentifier()).isEqualTo("some-local-part@example.org");
}
@Test
void preprocessEntityWithUninitializedIdentifier() {
// given
final var givenEntity = validEntityBuilder().identifier(null).build();
assertThat(givenEntity.getParentAsset().getIdentifier()).as("preconditon failed").isEqualTo("example.org|MBOX");
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when
validator.preprocessEntity(givenEntity);
// then
assertThat(givenEntity.getIdentifier())
.isEqualTo(givenEntity.getDirectValue("local-part", String.class) + "@example.org");
}
@Test @Test
void acceptsValidEntity() { void acceptsValidEntity() {
// given // given

View File

@ -9,18 +9,17 @@ import org.junit.jupiter.api.Test;
import java.util.Map; import java.util.Map;
import static java.util.Map.entry; import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetTestEntities.MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetTestEntities.MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsMariaDbInstanceHostingAssetValidator.DEFAULT_INSTANCE_IDENTIFIER_SUFFIX; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_INSTANCE;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsPostgreSqlDbInstanceHostingAssetValidator.DEFAULT_INSTANCE_IDENTIFIER_SUFFIX;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class HsPostgreSqlInstanceHostingAssetValidatorUnitTest { class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder() { static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder() {
return HsHostingAssetRbacEntity.builder() return HsHostingAssetRbacEntity.builder()
.type(MARIADB_INSTANCE) .type(PGSQL_INSTANCE)
.parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY)
.identifier(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX); .identifier(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX);
} }
@ -28,7 +27,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
@Test @Test
void containsExpectedProperties() { void containsExpectedProperties() {
// when // when
final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SMTP_SETUP); final var validator = HostingAssetEntityValidatorRegistry.forType(PGSQL_INSTANCE);
// then // then
assertThat(validator.properties()).map(Map::toString).isEmpty(); assertThat(validator.properties()).map(Map::toString).isEmpty();
@ -45,7 +44,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
validator.preprocessEntity(givenEntity); validator.preprocessEntity(givenEntity);
// then // then
assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|MariaDB.default"); assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|PgSql.default");
} }
@Test @Test
@ -64,7 +63,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
@Test @Test
void rejectsInvalidIdentifier() { void rejectsInvalidIdentifier() {
// given // given
final var givenEntity = validEntityBuilder().identifier("example.org").build(); final var givenEntity = validEntityBuilder().identifier("PostgreSQL").build();
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when // when
@ -72,7 +71,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactly( assertThat(result).containsExactly(
"'identifier' expected to match '^\\Qvm1234|MariaDB.default\\E$', but is 'example.org'" "'identifier' expected to match '^\\Qvm1234|PgSql.default\\E$', but is 'PostgreSQL'"
); );
} }
@ -91,9 +90,9 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactlyInAnyOrder( assertThat(result).containsExactlyInAnyOrder(
"'MARIADB_INSTANCE:vm1234|MariaDB.default.bookingItem' must be null but is of type CLOUD_SERVER", "'PGSQL_INSTANCE:vm1234|PgSql.default.bookingItem' must be null but is of type CLOUD_SERVER",
"'MARIADB_INSTANCE:vm1234|MariaDB.default.parentAsset' must be of type MANAGED_SERVER but is of type MANAGED_WEBSPACE", "'PGSQL_INSTANCE:vm1234|PgSql.default.parentAsset' must be of type MANAGED_SERVER but is of type MANAGED_WEBSPACE",
"'MARIADB_INSTANCE:vm1234|MariaDB.default.assignedToAsset' must be null but is of type MANAGED_WEBSPACE"); "'PGSQL_INSTANCE:vm1234|PgSql.default.assignedToAsset' must be null but is of type MANAGED_WEBSPACE");
} }
@Test @Test
@ -111,6 +110,6 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
// then // then
assertThat(result).containsExactlyInAnyOrder( assertThat(result).containsExactlyInAnyOrder(
"'MARIADB_INSTANCE:vm1234|MariaDB.default.config.any' is not expected but is set to 'false'"); "'PGSQL_INSTANCE:vm1234|PgSql.default.config.any' is not expected but is set to 'false'");
} }
} }

View File

@ -0,0 +1,180 @@
package net.hostsharing.hsadminng.hs.migration;
import liquibase.Liquibase;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.jdbc.ContainerDatabaseDriver;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import javax.sql.DataSource;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.io.FileUtils.readFileToString;
import static org.apache.commons.io.FileUtils.write;
import static org.apache.commons.io.FileUtils.writeStringToFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
// BLOG: Liquibase-migration-test (not before the reference-SQL-dump-generation is simplified)
// HOWTO: generate the prod-reference-SQL-dump during a prod-release
/**
* Tests, if the Liquibase scripts can be applied to a database which is already populated with schemas
* and test-data from a previous version.
*
* <p>The test works as follows:</p>
*
* <ol>
* <li>the database is initialized by `db/prod-only-office-schema-with-test-data.sql` from the test-resources</li>
* <li>the current Liquibase-migrations (only-office but with-test-data) are performed</li>
* <li>a new dump is written to `db/prod-only-office-schema-with-test-data.sql` in the build-directory</li>
* <li>an extra Liquibase-changeset (liquibase-migration-test) is applied</li>
* <li>it's asserted that the extra changeset got applied</li>
* </ol>
*
* <p>During a release, the generated dump has to be committed to git and will be used in future test-runs
* until it gets replaced with a new dump at the next release.</p>
*/
@Tag("officeIntegrationTest")
@DataJpaTest(properties = {
"spring.datasource.url=jdbc:tc:postgresql:15.5-bookworm:///liquibaseMigrationTestTC",
"spring.liquibase.enabled=false" // @Sql should go first, Liquibase will be initialized programmatically
})
@DirtiesContext
@ActiveProfiles("liquibase-migration-test")
@Import(LiquibaseConfig.class)
@Sql(value = "/db/prod-only-office-schema-with-test-data.sql", executionPhase = BEFORE_TEST_CLASS)
public class LiquibaseCompatibilityIntegrationTest {
private static final String EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION = "hs-global-liquibase-migration-test";
@Autowired
private DataSource dataSource;
@Autowired
private Liquibase liquibase;
@PersistenceContext
private EntityManager em;
@Test
void migrationWorksBasedOnAPreviouslyPopulatedSchema() {
// check the initial status from the @Sql-annotation
final var initialChangeSetCount = assertProdReferenceStatusAfterRestore();
// run the current migrations and dump the result to the build-directory
runLiquibaseMigrationsWithContexts("only-office", "with-test-data");
dumpTo(new File("build/db/prod-only-office-schema-with-test-data.sql"));
// then add another migration and assert if it was applied
runLiquibaseMigrationsWithContexts("liquibase-migration-test");
assertThatCurrentMigrationsGotApplied(initialChangeSetCount);
}
private int assertProdReferenceStatusAfterRestore() {
final var schemas = singleColumnSqlQuery("SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'");
assertThat(schemas).containsExactly("databasechangelog", "databasechangeloglock");
final var liquibaseScripts1 = singleColumnSqlQuery("SELECT * FROM public.databasechangelog");
assertThat(liquibaseScripts1).hasSizeGreaterThan(285);
assertThat(liquibaseScripts1).doesNotContain(EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION);
final var initialChangeSetCount = liquibaseScripts1.size();
return initialChangeSetCount;
}
private void assertThatCurrentMigrationsGotApplied(final int initialChangeSetCount) {
final var liquibaseScripts = singleColumnSqlQuery("SELECT id FROM public.databasechangelog");
assertThat(liquibaseScripts).hasSizeGreaterThan(initialChangeSetCount);
assertThat(liquibaseScripts).contains(EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION);
}
@SneakyThrows
private void dumpTo(final File targetFileName) {
makeDir(targetFileName.getParentFile());
final var jdbcDatabaseContainer = getJdbcDatabaseContainer();
final var sqlDumpFile = new File(targetFileName.getParent(), "." + targetFileName.getName());
final var pb = new ProcessBuilder(
"pg_dump", "--column-inserts", "--disable-dollar-quoting",
"--host=" + jdbcDatabaseContainer.getHost(),
"--port=" + jdbcDatabaseContainer.getFirstMappedPort(),
"--username=" + jdbcDatabaseContainer.getUsername() ,
"--dbname=" + jdbcDatabaseContainer.getDatabaseName(),
"--file=" + sqlDumpFile.getCanonicalPath()
);
pb.environment().put("PGPASSWORD", jdbcDatabaseContainer.getPassword());
final var process = pb.start();
int exitCode = process.waitFor();
final var stderr = new BufferedReader(new InputStreamReader(process.getErrorStream()))
.lines().collect(Collectors.joining("\n"));
assertThat(exitCode).describedAs(stderr).isEqualTo(0);
final var header = """
-- =================================================================================
-- Generated reference-SQL-dump (hopefully of latest prod-release).
-- See: net.hostsharing.hsadminng.hs.migration.LiquibaseCompatibilityIntegrationTest
-- ---------------------------------------------------------------------------------
--
-- Explicit pre-initialization because we cannot use `pg_dump --create ...`
-- because the database is already created by Testcontainers.
--
CREATE ROLE postgres;
CREATE ROLE admin;
CREATE ROLE restricted;
""";
writeStringToFile(targetFileName, header, UTF_8, false); // false = overwrite
write(targetFileName, readFileToString(sqlDumpFile, UTF_8), UTF_8, true);
assertThat(sqlDumpFile.delete()).describedAs(sqlDumpFile + " cannot be deleted");
}
private void makeDir(final File dir) {
assertThat(!dir.exists() || dir.isDirectory()).describedAs(dir + " does exist, but is not a directory").isTrue();
assertThat(dir.isDirectory() || dir.mkdirs()).describedAs(dir + " cannot be created").isTrue();
}
@SneakyThrows
private void runLiquibaseMigrationsWithContexts(final String... contexts) {
liquibase.update(
new liquibase.Contexts(contexts),
new liquibase.LabelExpression());
}
private List<String> singleColumnSqlQuery(final String sql) {
//noinspection unchecked
final var rows = (List<Object>) em.createNativeQuery(sql).getResultList();
return rows.stream().map(Objects::toString).toList();
}
@SneakyThrows
private static JdbcDatabaseContainer<?> getJdbcDatabaseContainer() {
final var getContainerMethod = ContainerDatabaseDriver.class.getDeclaredMethod("getContainer", String.class);
getContainerMethod.setAccessible(true);
@SuppressWarnings("rawtypes")
final var container = (JdbcDatabaseContainer) getContainerMethod.invoke(null,
"jdbc:tc:postgresql:15.5-bookworm:///liquibaseMigrationTestTC");
return container;
}
}

View File

@ -0,0 +1,28 @@
package net.hostsharing.hsadminng.hs.migration;
import liquibase.Liquibase;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.sql.DataSource;
@Configuration
@Profile("liquibase-migration-test")
public class LiquibaseConfig {
@Bean
public Liquibase liquibase(DataSource dataSource) throws Exception {
final var connection = dataSource.getConnection();
final var database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
return new Liquibase(
"db/changelog/db.changelog-master.yaml", // Path to your Liquibase changelog
new ClassLoaderResourceAccessor(),
database
);
}
}

File diff suppressed because it is too large Load Diff