Compare commits

...

2 Commits

Author SHA1 Message Date
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
11 changed files with 17393 additions and 40 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 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
@ -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.
### Dependency-License-Compatibility
### How to Check Dependency-License-Compatibility
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.
@ -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).
### Dependency Version Upgrade
### How to Upgrade Versions of Dependencies
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.
We would also reduce the depth of the expensive recursive CTE-query.
This has to be explored further.
For now, we just keep it in mind and FIXME
This has to be explored further. For now, we just keep it in mind and avoid roles+grants
which would not fit into a simplified system with a fixed role-type-system.
### The Mapper is Error-Prone

View File

@ -1,15 +1,15 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
id 'io.openapiprocessor.openapi-processor' version '2023.2'
id 'com.github.jk1.dependency-license-report' version '2.9'
id "org.owasp.dependencycheck" version "12.0.0"
id "com.diffplug.spotless" version "7.0.2"
id 'jacoco'
id 'info.solidsoft.pitest' version '1.15.0'
id 'se.patrikerdes.use-latest-versions' version '0.2.18'
id 'com.github.ben-manes.versions' version '0.51.0'
id 'io.spring.dependency-management' version '1.1.7' // manages implicit dependencies
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' // checks dependency-license compatibility
id "org.owasp.dependencycheck" version "12.0.1" // checks dependencies for known vulnerabilities
id "com.diffplug.spotless" version "7.0.2" // formats + checks formatting for source-code
id 'jacoco' // determines code-coverage of tests
id 'info.solidsoft.pitest' version '1.15.0' // performs mutation testing
id 'se.patrikerdes.use-latest-versions' version '0.2.18' // updates module and plugin versions
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:
@ -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
project.tasks.spotlessJava.dependsOn(
tasks.generateLicenseReport,
tasks.pitest,
// tasks.pitest, TODO.test: PiTest currently does not work, needs to be fixed
tasks.jacocoTestReport,
tasks.processResources,
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) {
useJUnitPlatform {
excludeTags 'importOfficeData', 'importHostingAssets', 'scenarioTest', 'generalIntegrationTest',
@ -360,7 +360,7 @@ tasks.register('generalIntegrationTest', Test) {
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) {
useJUnitPlatform {
includeTags 'officeIntegrationTest'
@ -372,26 +372,26 @@ tasks.register('officeIntegrationTest', Test) {
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) {
useJUnitPlatform {
includeTags 'bookingIntegrationTest'
}
group 'verification'
description 'runs integration tests of the office module'
description 'runs integration tests of the booking module'
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) {
useJUnitPlatform {
includeTags 'hostingIntegrationTest'
}
group 'verification'
description 'runs integration tests of the office module'
description 'runs integration tests of the hosting module'
mustRunAfter spotlessJava
}
@ -454,7 +454,7 @@ pitest {
outputFormats = ['XML', 'HTML']
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!
println "PiTest Mutation Report: file:///${project.rootDir}/build/reports/pitest/index.html"
}

View File

@ -3,7 +3,7 @@
-- ============================================================================
-- NUMERIC-HASH-FUNCTIONS
--changeset michael.hoennig:hash endDelimiter:--//
--changeset michael.hoennig:hash runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ----------------------------------------------------------------------------
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 $$
begin
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;
end if;
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;
end if;
end $$;

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.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {"server.port=0", "hsadminng.cas.server=http://localhost:8088/cas"})
@ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!

View File

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

View File

@ -9,18 +9,17 @@ import org.junit.jupiter.api.Test;
import java.util.Map;
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.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.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;
class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder() {
return HsHostingAssetRbacEntity.builder()
.type(MARIADB_INSTANCE)
.type(PGSQL_INSTANCE)
.parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY)
.identifier(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX);
}
@ -28,7 +27,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
@Test
void containsExpectedProperties() {
// when
final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SMTP_SETUP);
final var validator = HostingAssetEntityValidatorRegistry.forType(PGSQL_INSTANCE);
// then
assertThat(validator.properties()).map(Map::toString).isEmpty();
@ -45,7 +44,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
validator.preprocessEntity(givenEntity);
// then
assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|MariaDB.default");
assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|PgSql.default");
}
@Test
@ -64,7 +63,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
@Test
void rejectsInvalidIdentifier() {
// given
final var givenEntity = validEntityBuilder().identifier("example.org").build();
final var givenEntity = validEntityBuilder().identifier("PostgreSQL").build();
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when
@ -72,7 +71,7 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
// then
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
assertThat(result).containsExactlyInAnyOrder(
"'MARIADB_INSTANCE:vm1234|MariaDB.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",
"'MARIADB_INSTANCE:vm1234|MariaDB.default.assignedToAsset' must be null but is of type MANAGED_WEBSPACE");
"'PGSQL_INSTANCE:vm1234|PgSql.default.bookingItem' must be null but is of type CLOUD_SERVER",
"'PGSQL_INSTANCE:vm1234|PgSql.default.parentAsset' must be of type MANAGED_SERVER but is of type MANAGED_WEBSPACE",
"'PGSQL_INSTANCE:vm1234|PgSql.default.assignedToAsset' must be null but is of type MANAGED_WEBSPACE");
}
@Test
@ -111,6 +110,6 @@ class HsPostgreSqlInstanceHostingAssetValidatorUnitTest {
// then
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,136 @@
package net.hostsharing.hsadminng.hs.migration;
import liquibase.Liquibase;
import liquibase.exception.LiquibaseException;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.BeforeEach;
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 javax.sql.DataSource;
import java.util.List;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
// TODO.impl: The reference-SQL-dump-generation needs to be automated
// 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 ionitialized with schemas
* and test-data from a previous version.
*
* <p>The test needs a dump, ideally from the version of the lastest prod-release:</p>
*
* <ol>
* <li>clean the database:<br/>
* <code>pg-sql-reset</code>
* </li>
*
* <li>restote the database from latest dump</br>
* <pre><code>
* docker exec -i hsadmin-ng-postgres psql -U postgres postgres \
* <src/test/resources/db/prod-only-office-schema-with-test-data.sql
* </code></pre>
* </li>
*
* <li>run the missing migrations:</br>
* <code>gw bootRun --args='--spring.profiles.active=only-office'</code>
* </li>
*
* <li>create the reference-schema SQL-file with some initializations:</li>
* <pre><code>
* cat >src/test/resources/db/prod-only-office-schema-with-test-data.sql <<EOF
* -- =================================================================================
* -- 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;
* GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO admin;
* CREATE ROLE restricted;
* GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO restricted;
*
* EOF
* </code></pre>
* </li>
*
* <li>add the dump to that reference-schema SQL-file:</p>
* <pre><code>docker exec -i hsadmin-ng-postgres /usr/bin/pg_dump \
* --column-inserts --disable-dollar-quoting -U postgres postgres \
* >>src/test/resources/db/prod-only-office-schema-with-test-data.sql
* </code></pre>
* </li>
* </ol>
*
* <p>The generated dump has to be committed to git and will be used in future test-runs
* until it gets replaced at the next release.</p>
*/
@Tag("officeIntegrationTest")
@DataJpaTest(properties = {
"spring.liquibase.enabled=false" // @Sql should go first, Liquibase will be initialized programmatically
})
@DirtiesContext
@ActiveProfiles("liquibase-migration-test")
@Import({ Context.class, JpaAttempt.class, LiquibaseConfig.class })
@Sql(value = "/db/prod-only-office-schema-with-test-data.sql", executionPhase = BEFORE_TEST_CLASS)
public class LiquibaseCompatibilityIntegrationTest extends CsvDataImport {
private static final String EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION = "hs-hosting-SCHEMA";
private static int initialChangeSetCount = 0;
@Autowired
private DataSource dataSource;
@Autowired
private Liquibase liquibase;
@BeforeEach
public void setup() throws Exception {
assertThatDatabaseIsInitialized();
runLiquibaseMigrations();
}
@Test
void test() {
final var liquibaseScripts = singleColumnSqlQuery("SELECT id FROM public.databasechangelog");
assertThat(liquibaseScripts).hasSizeGreaterThan(initialChangeSetCount);
assertThat(liquibaseScripts).contains(EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION);
}
private void assertThatDatabaseIsInitialized() {
final var schemas = singleColumnSqlQuery("SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'");
assertThat(schemas).containsExactly("databasechangelog", "databasechangeloglock");
final var liquibaseScripts = singleColumnSqlQuery("SELECT * FROM public.databasechangelog");
assertThat(liquibaseScripts).hasSizeGreaterThan(285);
assertThat(liquibaseScripts).doesNotContain(EXPECTED_CHANGESET_ONLY_AFTER_NEW_MIGRATION);
initialChangeSetCount = liquibaseScripts.size();
}
private void runLiquibaseMigrations() throws LiquibaseException {
liquibase.update(new liquibase.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();
}
}

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