diff --git a/.aliases b/.aliases index 9eef231d..f50f6247 100644 --- a/.aliases +++ b/.aliases @@ -1,9 +1,6 @@ -# For using the alias import-office-tables, # copy these exports to .environment (ignored by git) -# and amend them according to your external DB: -export HSADMINNG_POSTGRES_JDBC_URL=jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers -export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin -export HSADMINNG_POSTGRES_ADMIN_PASSWORD= -export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted +# For using the alias gw-importOfficeData or gw-importHostingAssets, +# copy the file .tc-environment to .environment (ignored by git) +# and amend them according to your external DB. gradleWrapper () { if [ ! -f gradlew ]; then @@ -45,23 +42,29 @@ postgresAutodoc () { } alias postgres-autodoc=postgresAutodoc -function importOfficeData() { - export HSADMINNG_POSTGRES_JDBC=jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers - export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin - export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password - export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted - export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net - - if [ -f .environment ]; then - source .environment - fi +function importLegacyData() { + export target=$1 + if [ -z "$target" ]; then + echo "importLegacyData needs target argument, but none was given" >&2 + else + source .tc-environment - echo "using environment (with ending ';' for use in IntelliJ IDEA):" - set | grep ^HSADMINNG_ | sed 's/$/;/' + if [ -f .environment ]; then + source .environment + fi - ./gradlew importOfficeData --rerun + echo "using environment (with ending ';' for use in IntelliJ IDEA):" + echo "--- BEGIN: ---" + set | grep ^HSADMINNG_ | sed 's/$/;/' + echo "---- END. ----" + echo + + echo ./gradlew $target --rerun + ./gradlew $target --rerun + fi } -alias gw-importOfficeData=importOfficeData +alias gw-importOfficeData='importLegacyData importOfficeData' +alias gw-importHostingAssets='importLegacyData importHostingAssets' alias podman-start='systemctl --user enable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock' alias podman-stop='systemctl --user disable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock' @@ -79,5 +82,16 @@ alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l' alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResources' -alias gw-test='. .aliases; ./gradlew test importOfficeData' +alias gw-test='. .aliases; ./gradlew test' alias gw-check='. .aliases; gw test importOfficeData check -x pitest -x :dependencyCheckAnalyze' + +# etc/docker-compose.yml limits CPUs+MEM and includes a PostgreSQL config for analysing slow queries +alias gw-importOfficeData-in-docker-compose=' + docker-compose -f etc/docker-compose.yml down && + docker-compose -f etc/docker-compose.yml up -d && sleep 10 && + time gw-importHostingAssets' + +if [ ! -f .environment ]; then + cp .tc-environment .environment +fi +source .environment diff --git a/.gitignore b/.gitignore index d6a2e347..bd8ec3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ /build/www/** /src/test/javascript/coverage/ /worktrees/ -TODO-progress.png ###################### # Node @@ -137,4 +136,9 @@ Desktop.ini # ESLint ###################### .eslintcache + +###################### +# Project Related +###################### /.environment* +/src/test/resources/migration-prod/* diff --git a/.run/ImportHostingAssets into local.run.xml b/.run/ImportHostingAssets into local.run.xml new file mode 100644 index 00000000..d3c7f2da --- /dev/null +++ b/.run/ImportHostingAssets into local.run.xml @@ -0,0 +1,37 @@ + + + + + + + + false + true + + + + false + true + + + \ No newline at end of file diff --git a/.run/ImportHostingAssets.run.xml b/.run/ImportHostingAssets.run.xml new file mode 100644 index 00000000..bedd7143 --- /dev/null +++ b/.run/ImportHostingAssets.run.xml @@ -0,0 +1,36 @@ + + + + + + + + false + true + + + + false + true + + + \ No newline at end of file diff --git a/.run/ImportOfficeData.run.xml b/.run/ImportOfficeData.run.xml new file mode 100644 index 00000000..c146186e --- /dev/null +++ b/.run/ImportOfficeData.run.xml @@ -0,0 +1,103 @@ + + + + + + + + false + true + + + + false + true + + + + + + + + + false + true + + + + false + true + + + + + + + + + false + true + + + + false + true + + + \ No newline at end of file diff --git a/.run/README.txt b/.run/README.txt new file mode 100644 index 00000000..96094ded --- /dev/null +++ b/.run/README.txt @@ -0,0 +1 @@ +Stored run-Configurations for IntelliJ IDEA. diff --git a/.tc-environment b/.tc-environment new file mode 100644 index 00000000..ecc6dc9a --- /dev/null +++ b/.tc-environment @@ -0,0 +1,8 @@ +unset HSADMINNG_POSTGRES_JDBC_URL # dynamically set, different for normal tests and imports +export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin +export HSADMINNG_POSTGRES_ADMIN_PASSWORD= +export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted +export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net +export HSADMINNG_MIGRATION_DATA_PATH=migration +export LIQUIBASE_CONTEXT= +export LANG=en_US.UTF-8 diff --git a/.unset-environment b/.unset-environment new file mode 100644 index 00000000..a9e4ee81 --- /dev/null +++ b/.unset-environment @@ -0,0 +1,8 @@ +unset HSADMINNG_POSTGRES_JDBC_URL +unset HSADMINNG_POSTGRES_ADMIN_USERNAME +unset HSADMINNG_POSTGRES_ADMIN_PASSWORD +unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME +unset HSADMINNG_SUPERUSER +unset HSADMINNG_MIGRATION_DATA_PATH +unset LIQUIBASE_CONTEXT + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8406f976 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# build using: +# docker build -t postgres-with-contrib:15.5-bookworm . + +FROM postgres:15.5-bookworm + +RUN apt-get update && \ + apt-get install -y postgresql-contrib && \ + apt-get clean + +COPY etc/postgresql-log-slow-queries.conf /etc/postgresql/postgresql.conf diff --git a/README.md b/README.md index 01227278..4d03a6d3 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,10 @@ Everything is tested on _Ubuntu Linux 22.04_ and _MacOS Monterey (12.4)_. To be able to build and run the Java Spring Boot application, you need the following tools: -- git, e.g. via `sudo apt install git` -- A Java Runtime (JRE) at least version 17 to run the gradle wrapper, e.g. via - `sudo apt install openjdk-17-jre-headless`. - (The matching Java JDK for building the application will be automatically installed by Gradle toolchain support.) -- Docker 20.x (on MacOS you also need *Docker Desktop* or similar) or Podman. +- Docker 20.x (on MacOS you also need *Docker Desktop* or similar) or Podman - optionally: PostgreSQL Server 15.5-bookworm (see instructions below to install and run in Docker) +- The matching Java JDK at will be automatically installed by Gradle toolchain support to `~/.gradle/jdks/`. - You also might need an IDE (e.g. *IntelliJ IDEA* or *Eclipse* or *VS Code* with *[STS](https://spring.io/tools)* and a GUI Frontend for *PostgreSQL* like *Postbird*. If you have at least Docker and the Java JDK installed in appropriate versions and in your `PATH`, then you can start like this: @@ -85,7 +82,7 @@ If you have at least Docker and the Java JDK installed in appropriate versions a # the following command should return a JSON array with just all packages visible for the admin of the customer yyy: curl \ - -H 'current-user: superuser-alex@hostsharing.net' -H 'assumed-roles: test_customer#yyy.admin' \ + -H 'current-user: superuser-alex@hostsharing.net' -H 'assumed-roles: test_customer#yyy:ADMIN' \ http://localhost:8080/api/test/packages # add a new customer @@ -383,12 +380,6 @@ You can explore the prototype as follows: `src/` The actual source-code, see [Source Code Package Structure](#source-code-package-structure) for details. -`TODO.md` - Requirements of initial project. Do not touch! - -`TODO-progress.png` - Generated diagram image of the project progress. - `tools/` Some shell-scripts to useful tasks. @@ -768,5 +759,4 @@ The output will list the generated files. ## Further Documentation - the `doc` directory contains architecture concepts and a glossary -- TODO.md tracks requirements and progress for the contract of the initial project, - please do not amend anything in this document +- the `ideas` directory contains unstructured ideas for future development or documentation diff --git a/build.gradle b/build.gradle index 285fa8d9..41ceaed8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,15 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.1.7' + id 'org.springframework.boot' version '3.2.4' id 'io.spring.dependency-management' version '1.1.4' id 'io.openapiprocessor.openapi-processor' version '2023.2' - id 'com.github.jk1.dependency-license-report' version '2.5' - id "org.owasp.dependencycheck" version "9.0.7" - id "com.diffplug.spotless" version "6.23.3" + id 'com.github.jk1.dependency-license-report' version '2.6' + id "org.owasp.dependencycheck" version "9.0.10" + id "com.diffplug.spotless" version "6.25.0" 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.50.0' + id 'com.github.ben-manes.versions' version '0.51.0' } group = 'net.hostsharing' @@ -59,35 +59,24 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.9.1' - implementation 'org.springdoc:springdoc-openapi:2.3.0' - implementation 'org.postgresql:postgresql:42.7.1' - implementation 'org.liquibase:liquibase-core:4.25.1' - implementation 'com.vladmihalcea:hibernate-types-60:2.21.1' - implementation 'io.hypersistence:hypersistence-utils-hibernate-62:3.7.0' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1' + implementation 'org.springdoc:springdoc-openapi:2.4.0' + implementation 'org.postgresql:postgresql:42.7.3' + implementation 'org.liquibase:liquibase-core:4.27.0' + implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.3' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0' implementation 'org.openapitools:jackson-databind-nullable:0.2.6' implementation 'org.apache.commons:commons-text:1.11.0' + implementation 'net.java.dev.jna:jna:5.8.0' implementation 'org.modelmapper:modelmapper:3.2.0' implementation 'org.iban4j:iban4j:3.2.7-RELEASE' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - - // fixes vulnerability CVE-2022-1471 - // The dependency usually comes from Spring Boot, just in the wrong version. - // TODO: Remove this explicit dependency once we are on SpringBoot 3.2.x - // as well as the related exclude in settings.gradle - // and the dependency suppression in owasp-dependency-check-suppression.xml. - implementation('org.yaml:snakeyaml') { - version { - strictly('2.2') - } - } + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' + implementation 'org.reflections:reflections:0.9.12' compileOnly 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - annotationProcessor 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' @@ -152,7 +141,7 @@ openapiProcessor { showWarnings true openApiNullable true } - springHs { + springHsOffice { processorName 'spring' processor 'io.openapiprocessor:openapi-processor-spring:2022.5' apiPath "$projectDir/src/main/resources/api-definition/hs-office/hs-office.yaml" @@ -161,18 +150,44 @@ openapiProcessor { showWarnings true openApiNullable true } + springHsBooking { + processorName 'spring' + processor 'io.openapiprocessor:openapi-processor-spring:2022.5' + apiPath "$projectDir/src/main/resources/api-definition/hs-booking/hs-booking.yaml" + mapping "$projectDir/src/main/resources/api-definition/hs-booking/api-mappings.yaml" + targetDir "$buildDir/generated/sources/openapi-javax" + showWarnings true + openApiNullable true + } + springHsHosting { + processorName 'spring' + processor 'io.openapiprocessor:openapi-processor-spring:2022.5' + apiPath "$projectDir/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml" + mapping "$projectDir/src/main/resources/api-definition/hs-hosting/api-mappings.yaml" + targetDir "$buildDir/generated/sources/openapi-javax" + showWarnings true + openApiNullable true + } } sourceSets.main.java.srcDir 'build/generated/sources/openapi' abstract class ProcessSpring extends DefaultTask {} tasks.register('processSpring', ProcessSpring) -['processSpringRoot', 'processSpringRbac', 'processSpringTest', 'processSpringHs'].each { +['processSpringRoot', + 'processSpringRbac', + 'processSpringTest', + 'processSpringHsOffice', + 'processSpringHsBooking', + 'processSpringHsHosting' +].each { project.tasks.processSpring.dependsOn it } project.tasks.processResources.dependsOn processSpring project.tasks.compileJava.dependsOn processSpring // Rename javax to jakarta in OpenApi generated java files because -// io.openapiprocessor.openapi-processor 2022.2 does not yet support the openapiprocessor useSpringBoot3 config option. +// io.openapiprocessor.openapi-processor 2022.5 does not yet support the openapiprocessor useSpringBoot3 config option. +// TODO.impl: Upgrade to io.openapiprocessor.openapi-processor >= 2024.2 +// and use either `bean-validation: true` in api-mapping.yaml or `useSpringBoot3 true` (not sure where exactly). task openApiGenerate(type: Copy) { from "$buildDir/generated/sources/openapi-javax" into "$buildDir/generated/sources/openapi" @@ -303,13 +318,25 @@ jacocoTestCoverageVerification { tasks.register('importOfficeData', Test) { useJUnitPlatform { - includeTags 'import' + includeTags 'importOfficeData' } group 'verification' description 'run the import jobs as tests' + + mustRunAfter spotlessJava } +tasks.register('importHostingAssets', Test) { + useJUnitPlatform { + includeTags 'importHostingAssets' + } + + group 'verification' + description 'run the import jobs as tests' + + mustRunAfter spotlessJava +} // pitest mutation testing pitest { diff --git a/doc/adr/2022-07-18.row-level-security-mechanism.md b/doc/adr/2022-07-18.row-level-security-mechanism.md index 21225288..6276bd4d 100644 --- a/doc/adr/2022-07-18.row-level-security-mechanism.md +++ b/doc/adr/2022-07-18.row-level-security-mechanism.md @@ -74,7 +74,7 @@ For restricted DB-users, which are used by the backend, access to rows is filter FOR SELECT TO restricted USING ( - isPermissionGrantedToSubject(findPermissionId('customer', id, 'view'), currentUserUuid()) + isPermissionGrantedToSubject(findEffectivePermissionId('customer', id, 'view'), currentUserUuid()) ); SET SESSION AUTHORIZATION restricted; @@ -101,7 +101,7 @@ We are bound to PostgreSQL, including integration tests and testing the RBAC sys CREATE OR REPLACE RULE "_RETURN" AS ON SELECT TO cust_view DO INSTEAD - SELECT * FROM customer WHERE isPermissionGrantedToSubject(findPermissionId('customer', id, 'view'), currentUserUuid()); + SELECT * FROM customer WHERE isPermissionGrantedToSubject(findEffectivePermissionId('customer', id, 'view'), currentUserUuid()); SET SESSION AUTHORIZATION restricted; SET hsadminng.currentUser TO 'alex@example.com'; diff --git a/doc/hs-hosting-asset-type-structure.md b/doc/hs-hosting-asset-type-structure.md new file mode 100644 index 00000000..7f9a9ae9 --- /dev/null +++ b/doc/hs-hosting-asset-type-structure.md @@ -0,0 +1,218 @@ +## HostingAsset Type Structure + + +### Server+Webspace + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP +} + +package Hosting #feb28c{ + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IPV4_NUMBER + entity HA_IPV6_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_CLOUD_SERVER *==> BI_CLOUD_SERVER +HA_MANAGED_SERVER *==> BI_MANAGED_SERVER +HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE +HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_IPV4_NUMBER o..> HA_CLOUD_SERVER +HA_IPV4_NUMBER o..> HA_MANAGED_SERVER +HA_IPV4_NUMBER o..> HA_MANAGED_WEBSPACE +HA_IPV6_NUMBER o..> HA_CLOUD_SERVER +HA_IPV6_NUMBER o..> HA_MANAGED_SERVER +HA_IPV6_NUMBER o..> HA_MANAGED_WEBSPACE + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` + +### Domain + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP +} + +package Hosting #feb28c{ + package Domain #99bcdb { + entity HA_DOMAIN_SETUP + entity HA_DOMAIN_DNS_SETUP + entity HA_DOMAIN_HTTP_SETUP + entity HA_DOMAIN_SMTP_SETUP + entity HA_DOMAIN_MBOX_SETUP + entity HA_EMAIL_ADDRESS + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_DOMAIN_SETUP *==> BI_DOMAIN_SETUP +HA_DOMAIN_SETUP o..> HA_DOMAIN_SETUP +HA_DOMAIN_DNS_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_DNS_SETUP o--> HA_MANAGED_WEBSPACE +HA_DOMAIN_HTTP_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_HTTP_SETUP o--> HA_UNIX_USER +HA_DOMAIN_SMTP_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_SMTP_SETUP o--> HA_MANAGED_WEBSPACE +HA_DOMAIN_MBOX_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_MBOX_SETUP o--> HA_MANAGED_WEBSPACE +HA_EMAIL_ADDRESS *==> HA_DOMAIN_MBOX_SETUP + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` + +### MariaDB + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP +} + +package Hosting #feb28c{ + package MariaDB #99bcdb { + entity HA_MARIADB_INSTANCE + entity HA_MARIADB_USER + entity HA_MARIADB_DATABASE + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_MARIADB_USER *==> HA_MANAGED_WEBSPACE +HA_MARIADB_USER o--> HA_MARIADB_INSTANCE +HA_MARIADB_DATABASE *==> HA_MARIADB_USER + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` + +### PostgreSQL + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP +} + +package Hosting #feb28c{ + package PostgreSQL #99bcdb { + entity HA_PGSQL_INSTANCE + entity HA_PGSQL_USER + entity HA_PGSQL_DATABASE + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_PGSQL_USER *==> HA_MANAGED_WEBSPACE +HA_PGSQL_USER o--> HA_PGSQL_INSTANCE +HA_PGSQL_DATABASE *==> HA_PGSQL_USER + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` + +This code generated was by HsHostingAssetType.main, do not amend manually. diff --git a/doc/hs-office-data-structure.md b/doc/hs-office-data-structure.md index 960e572b..b84264d0 100644 --- a/doc/hs-office-data-structure.md +++ b/doc/hs-office-data-structure.md @@ -10,7 +10,7 @@ classDiagram namespace Partner { class partner-MeierGmbH - class role-MeierGmbH + class rel-MeierGmbH class personDetails-MeierGmbH class contactData-MeierGmbH class person-MeierGmbH @@ -19,28 +19,29 @@ classDiagram namespace Representatives { class person-FrankMeier class contactData-FrankMeier - class role-MeierGmbH-FrankMeier + class rel-MeierGmbH-FrankMeier } namespace Debitors { class debitor-MeierGmbH class contactData-MeierGmbH-Buha - class role-MeierGmbH-Buha + class rel-MeierGmbH-Buha } namespace Operations { class person-SabineMeier class contactData-SabineMeier - class role-MeierGmbH-SabineMeier + class rel-MeierGmbH-SabineMeier } namespace Enums { - class RoleType { + class RelationType { <> UNKNOWN + PARTNER + DEBITOR REPRESENTATIVE - ACCOUNTING OPERATIONS } @@ -64,9 +65,9 @@ classDiagram class partner-MeierGmbH { +Numeric partnerNumber: 12345 - +Role partnerRole + +Relation partnerRel } - partner-MeierGmbH *-- role-MeierGmbH + partner-MeierGmbH *-- rel-MeierGmbH class person-MeierGmbH { +personType: LEGAL @@ -90,32 +91,32 @@ classDiagram +emailAddresses: office@meier-gmbh.de } - class role-MeierGmbH { - +RoleType RoleType PARTNER + class rel-MeierGmbH { + +RelationType type PARTNER +Person anchor +Person holder - +Contact roleContact + +Contact contact } - role-MeierGmbH o-- person-HostsharingEG : anchor - role-MeierGmbH o-- person-MeierGmbH : holder - role-MeierGmbH o-- contactData-MeierGmbH + rel-MeierGmbH o-- person-HostsharingEG : anchor + rel-MeierGmbH o-- person-MeierGmbH : holder + rel-MeierGmbH o-- contactData-MeierGmbH %% --- Debitors --- class debitor-MeierGmbH { - +Partner partner - +Numeric[2] debitorNumberSuffix: 00 - +Role billingRole - +boolean billable: true - +String vatId: ID123456789 - +String vatCountryCode: DE - +boolean vatBusiness: true - +boolean vatReverseCharge: false + +Partner partner + +Numeric[2] debitorNumberSuffix: 00 + +Relation debitorRel + +boolean billable: true + +String vatId: ID123456789 + +String vatCountryCode: DE + +boolean vatBusiness: true + +boolean vatReverseCharge: false +BankAccount refundBankAccount - +String defaultPrefix: mei + +String defaultPrefix: mei } debitor-MeierGmbH o-- partner-MeierGmbH - debitor-MeierGmbH *-- role-MeierGmbH-Buha + debitor-MeierGmbH *-- rel-MeierGmbH-Buha class contactData-MeierGmbH-Buha { +postalAddress: Hauptstraße 5, 22345 Hamburg @@ -123,15 +124,15 @@ classDiagram +emailAddresses: buha@meier-gmbh.de } - class role-MeierGmbH-Buha { - +RoleType RoleType ACCOUNTING + class rel-MeierGmbH-Buha { + +RelationType type DEBITOR +Person anchor +Person holder - +Contact roleContact + +Contact contact } - role-MeierGmbH-Buha o-- person-MeierGmbH : anchor - role-MeierGmbH-Buha o-- person-MeierGmbH : holder - role-MeierGmbH-Buha o-- contactData-MeierGmbH-Buha + rel-MeierGmbH-Buha o-- person-MeierGmbH : anchor + rel-MeierGmbH-Buha o-- person-MeierGmbH : holder + rel-MeierGmbH-Buha o-- contactData-MeierGmbH-Buha %% --- Representatives --- @@ -148,15 +149,15 @@ classDiagram +emailAddresses: frank.meier@meier-gmbh.de } - class role-MeierGmbH-FrankMeier { - +RoleType RoleType REPRESENTATIVE + class rel-MeierGmbH-FrankMeier { + +RelationType type REPRESENTATIVE +Person anchor +Person holder - +Contact roleContact + +Contact contact } - role-MeierGmbH-FrankMeier o-- person-MeierGmbH : anchor - role-MeierGmbH-FrankMeier o-- person-FrankMeier : holder - role-MeierGmbH-FrankMeier o-- contactData-FrankMeier + rel-MeierGmbH-FrankMeier o-- person-MeierGmbH : anchor + rel-MeierGmbH-FrankMeier o-- person-FrankMeier : holder + rel-MeierGmbH-FrankMeier o-- contactData-FrankMeier %% --- Operations --- @@ -173,14 +174,14 @@ classDiagram +emailAddresses: sabine.meier@meier-gmbh.de } - class role-MeierGmbH-SabineMeier { - +RoleType RoleType OPERATIONAL + class rel-MeierGmbH-SabineMeier { + +RelationType type OPERATIONAL +Person anchor +Person holder - +Contact roleContact + +Contact contact } - role-MeierGmbH-SabineMeier o-- person-MeierGmbH : anchor - role-MeierGmbH-SabineMeier o-- person-SabineMeier : holder - role-MeierGmbH-SabineMeier o-- contactData-SabineMeier + rel-MeierGmbH-SabineMeier o-- person-MeierGmbH : anchor + rel-MeierGmbH-SabineMeier o-- person-SabineMeier : holder + rel-MeierGmbH-SabineMeier o-- contactData-SabineMeier ``` diff --git a/doc/ideas/rbac-schema-f.md b/doc/ideas/rbac-schema-f.md new file mode 100644 index 00000000..c71e7a9b --- /dev/null +++ b/doc/ideas/rbac-schema-f.md @@ -0,0 +1,83 @@ +*(this is just a scribbled draft, that's why it's still in German)* + +### *Schema-F* für Permissions, Rollen und Grants + +Permissions, Rollen und Grants werden in den INSERT/UPDATE/DELETE-Triggern von Geschäftsobjekten erzeugt und gelöscht. Das Löschen erfolgt meistens automatisch über das zugehörige RbacObject, die INSERT- und UPDATE-Trigger müssen jedoch in *pl/pgsql* ausprogrammiert werden. + +Das folgende Schema soll dabei unterstützen, die richtigen Permissions, Rollen und Grants festzulegen. + +An einigen Stellen ist vom *Initiator* die Rede. Als *Initiator* gilt derjenige User, der die Operation (INSERT oder UPDATE) durchführt bzw. eine explizit anzugebende Rolle des Users. +Wird keine solche explizite Rolle angegeben, gilt die granted Rolle als diejenige, als der das Grant erfolgt. + +#### Typ Root: Objekte, welche nur eine Spezialisierung bzw. Zusatzdaten für andere Objekte bereitstellen (z.B. Partner für Relations vom Typ Partner oder Partner Details für Partner) + +Objektorientiert gedacht, enthalten solche Objekte die Zusatzdaten einer Subklasse; die Daten im Partner erweitern also eine Relation vom Typ `partner`. + +- Dann muss dieses Objekt zeitlich nach dem Objekt erzeugt werden, auf dass es sich bezieht, also z.B. zeitlich nach der Relation. +- Es werden Delete (\*), Edit und View Permissions für dieses Objekt erzeugt. +- Es werden **keine** Rollen für dieses Objekt erzeugt. +- Statt eigener Rollen werden die o.g. Permissions passenden Rollen des Hauptobjekts zugewiesen (granted) bzw. aus denen entfernt (revoked). + - Handelt es sich um Zusatzdaten zum Zwecke der Spezialisierung, dann z.B. so: + - Delete (\*) <-- Owner des Hauptobjektes + - Edit <-- **Admin** des Hauptobjektes + - View <-- Agent des Hauptobjektes + - Handelt es sich um Zusatzdaten, für die sich Edit-Rechte delegieren lassen sollen (wie im Falle der Partner-Details eines Partners), dann z.B. so: + - Delete (\*) <-- Owner des Hauptobjektes + - Edit <-- **Agent** des Hauptobjektes + - View <-- Agent des Hauptobjektes +- Für die Rollenzuordnung zwischen referenzierten Objekten gilt: + - Für Objekte vom Typ Root werden die Rollen des zugehörigen Aggregator-Objektes verwendet. + - Gibt es Referenzen auf hierarchisch verbundene Objekte (z.B. Debitor.refundBankAccount) gilt folgende Faustregel: + ***Nach oben absteigen, nach unten halten oder aufsteigen.*** An einem fachlich übergeordneten Objekt wird also eine niedrigere Rolle (z.B. Debitor.ADMIN -> Partner.AGENT), einem fachlich untergeordneten Objekt eine gleichwertige Rolle (z.B. Partner.ADMIN -> Debitor.ADMIN) zugewiesen oder sogar aufgestiegen (Debitor.ADMIN -> Package.TENANT). + - Für Referenzen zwischen Objekten, die nicht hierarchisch zueinander stehen (z.B. Debitor und Bankverbindung), wird auf beiden seiten abgestiegen (also Debitor.ADMIN -> BankAccount.REFERRER und BankAccount.ADMIN -> Debitor.TENANT). + +Anmerkung: Der Typ-Begriff *Root* bezieht sich auf die Rolle im fachlichen Datenmodell. Im Bezug auf den Teilgraphen eines fachlichen Kontexts ist dies auch eine Wurzel im Sinne der Graphentheorie. Aber in anderen fachlichen Kontexten können auch diese Objekte von anderen Teilgraphen referenziert werden und werden dann zum inneren Knoten. + + +#### Typ Aggregator: Objekte, welche weitere Objekte zusammenfassen (z.B. Relation fasst zwei Persons und einen Contact zusammen) + +Solche Objekte verweisen üblicherweise auf Objekte vom Typ Leaf und werden oft von Objekten des Typs Root referenziert. + +- Es werden i.d.R. folgende Rollen für diese Objekte erzeugt: + - Owner, Admin, Agent, Tenent(, Guest?) +- Es werden Delete (\*), Edit und View Permissions für dieses Objekt erzeugt. +- Die Permissions werden den Rollen sinnvoll zugewiesen, z.B.: + - Owner -> Delete (\*) + - Admin --> Edit + - Tenant (oder ggf. Guest) --> View +- Außerdem werden folgende Grants erstellt bzw. entzogen: + - Initiator --> Owner + - Owner --> Admin + - Admin --> Referrer + - Admins der referenzierten Objekte werden Agent des Aggregators + - Tenants des Aggregators werden Referrer der referenzierten Objekte + +### Typ Leaf: Handelt es sich um ein Objekt, welches (außer zur Modellierung separater Permissions) keine Unterobjekte enthält (z.B. Person, Customer)? + +Solche Objekte werden üblicherweise von Objekten des Typs Aggregator, manchmal auch von Objekten des Typs Root, referenziert. + +- Es werden i.d.R. folgende Rollen für diese Objekte erzeugt: + - Owner, Admin, Referrer +- Es werden Delete (\*), Edit und View Permissions für dieses Objekt erzeugt. +- Die Permissions werden den Rollen sinnvoll zugewiesen, z.B.: + - Delete (\*) <-- Owner + - Edit <-- Admin + - View <-- Referrer +- Außerdem werden folgende Grants erstellt bzw. entzogen: + - Owner --> Admin + - Admin --> Referrer + +```mermaid +flowchart LR + +subgraph partnerDetails + direction TB + style partnerDetails fill:#eee + + perm:partnerDetails.*{{partnerDetails.*}} + role:partnerDetails.edit{{partnerDetails.edit}} + role:partnerDetails.view{{partnerDetails.view}} + + +end +``` diff --git a/doc/ideas/simplified-grant-structure.md b/doc/ideas/simplified-grant-structure.md new file mode 100644 index 00000000..d9b3cf44 --- /dev/null +++ b/doc/ideas/simplified-grant-structure.md @@ -0,0 +1,29 @@ +(this is just a scribbled idea, that's why it's still in German) + +Ich habe mal wieder vom RBAC-System geträumt 🙈 Ok, im Halbschlaf darüber nachgedacht trifft es wohl besser. Und jetzt frage ich mich, ob wir viel zu kompliziert gedacht haben. + +Bislang gingen wir ja davon aus, dass, wenn komplexe Entitäten (z.B. Partner) erzeugt werden, wir wir über den INSERT-Trigger den Rollen der verknüpften Entitäten (z.B. den Rollen der Personendaten des Partners) auch Rechte an den komplexeren Entitäten und umgekehrt geben müssen. + +Da die komplexen Entitäten nur mit gewissen verbundenen Entitäten überhaupt sinnvoll nutzbar sind und diese daher über INNSER JOINs mitladen, könnte sonst auch nur jemand diese Entitäten, der auch die SELECT-Permission an den verküpften Entitäten hat. + +Vor einigen Wochen hatten wir schon einmal darüber geredet, ob wir dieses Geflecht wirklich komplett durchplanen müssen, also über mehrere Stufen hinweg, oder ob sehr warscheinlich eh dieselben Leuten an den weiter entfernten Entitäten die nötien Rechte haben, weil dahinter dieselben User stehen. Also z.B. dass gewährleistet ist, dass jemand mit ADMIN-Recht an den Personendaten des Partners auch bis in die SEPA-Mandate eines Debitors hineinsehen kann. + +Und nun gehe ich noch einen Schritt weiter: Könnte es nicht auch andersherum sein? Also wenn jemand z.B. SELECT-Recht am Partner hat, dass wir davon ausgehen können, dass derjenige auch die Partner-Personen- und Kontaktdaten sehen darf, und zwar implizit durch seine Partner-SELECT-Permission und ohne dass er explizit Rollen für diese Partner-Personen oder Kontaktdaten inne hat? + +Im Halbschlaf kam mir nur die Idee, warum wir nicht einfach die komplexen JPA-Entitäten zwar auf die restricted View setzen, wie bisher, aber für die verknüpften Entitäten auf die direkten (bisher "Raw..." genannt) Entitäten gehen. Dann könnte jemand mit einer Rolle, welche die SELECT-Permission auf die komplexe JPA-Entität (z.B.) Partner inne hat, auch die dazugehörige Relation(ship) ["Relation" wurde vor kurzem auf kurz "Relation" umbenannt] und die wiederum dazu gehörigen Personen- und Kontaktdaten lesen, ohne dass in einem INSERT- und UPDATE-Trigger der Partner-Entität die ganzen Grants mit den verknüpften Entäten aufgebaut und aktualisiert werden müssen. + +Beim Debitor ist das nämlich selbst mit Generator die Hölle, zumal eben auch Querverbindungen gegranted werden müssen, z.B. von der Debitor-Person zum Sema-Mandat - jedenfalls wenn man nicht Gefahr laufen wollte, dass jemand mit Admin-Rechten an der Partner-Person (also z.B. ein Repräsentant des Partners) die Sepa-Mandate der Debitoren gar nicht mehr sehen kann. Natürlich bräuchte man immer noch die Agent-Rolle am Partner und Debitor (evtl. repräsentiert durch die jeweils zugehörigen Relation - falls dieser Trick überhaupt noch nötig wäre), sowie ein Grant vom Partner-Agent auf den Debitor-Agent und vom Debitor-Agent auf die Sepa-Mandate-Admins, aber eben ohne filigran die ganzen Neben-Entäten (Personen- und Kontaktdaten von Partner und Debitor sowie Bank-Account) in jedem Trigger berücksichtigen zu müssen. Beim Refund-Bank-Account sogar besonders ätzend, weil der optional ist und dadurch zig "if ...refundBankAccountUuid is not null then ..." im Code enstehen (wenn der auch generiert ist). + +Mit anderen Worten, um als Repräsentant eines Geschäftspartners auf den Bank-Account der Sepa-Mandate sehen zu dürfen, wird derzeut folgende Grant-Kette durchlaufen (bzw. eben noch nicht, weil es noch nicht funktioniert): + +User -> Partner-Holder-Person:ADMIN -> Partner-Relation:AGENT -> Debitor-Relation:AGENT -> Sepa-Mandat:ADMIN -> BankAccount:ADMIN -> BankAccount:SELECT + +Daraus würde: + +User -> Partner-Relation:AGENT -> Debitor-Relation:AGENT -> Sepa-Mandat:ADMIN -> Sepa-Mandat:SELECT* + +(*mit JOIN auf RawBankAccount, also implizitem Leserecht) + +Das klingt zunächst nach nur einer marginalen Vereinfachung, die eigentlich Vereinfachung liegt aber im Erzeugen der Grants in den Triggern, denn da sind zudem noch Partner-Anchor-Person, Debitor-Holder- und Anchor-Person, Partner- und Debitor-Contact sowie der RefundBankAccount zu berücksichtigen. Und genau diese Grants würden großteils wegfallen, und durch implizite Persmissions über die JOINs auf die Raw-Tables ersetzt werden. Den refundBankAccound müssten wir dann, analog zu den Sepa-Mandataten, umgedreht modellieren, da den sonst + +Man könnte das Ganze auch als "Entwicklung der Rechtestruktur für Hosting-Entitäten auf der obersten Ebene" (Manged Webspace, Managed Server, Cloud Server etc.) sehen, denn die hängen alle unter dem Mega-komplexen Debitor. diff --git a/doc/projects-booking-items-and-hosting-entities.md b/doc/projects-booking-items-and-hosting-entities.md new file mode 100644 index 00000000..e2a2ba83 --- /dev/null +++ b/doc/projects-booking-items-and-hosting-entities.md @@ -0,0 +1,288 @@ +## HSAdmin-NG +### Project/BookingItems/HostingEntities + +__ATTENTION__: The notation uses UML clas diagram elements, but partly with different meanings. See Agenda. + +```mermaid +classDiagram + direction TD + + Partner o-- "0..n" Membership + Partner *-- "1..n" Debitor + Debitor *-- "1..n" Project + + Project o-- "0..n" PrivateCloudBI + Project o-- "0..n" CloudServerBI + Project o-- "0..n" ManagedServerBI + Project o-- "0..n" ManagedWebspaceBI + + PrivateCloudBI o-- "0..n" ManagedServerBI + PrivateCloudBI o-- "0..n" CloudServerBI + + CloudServerBI *-- CloudServerHE + + ManagedServerBI *-- ManagedServerHE + ManagedServerBI o-- "0..n" ManagedWebspaceBI + ManagedWebspaceBI *-- ManagedWebspaceHE + + ManagedWebspaceHE *-- "1..n" UnixUserHE + ManagedWebspaceHE o-- "0..n" DomainDNSSetupHE + ManagedWebspaceHE o-- "0..n" DomainHttpSetupHE + ManagedWebspaceHE o-- "0..n" DomainEMailSetupHE + ManagedWebspaceHE o-- "0..n" EMailAliasHE + DomainEMailSetupHE o-- "0..n" EMailAddressHE + ManagedWebspaceHE o-- "0..n" MariaDBUserHE + MariaDBUserHE o-- "0..n" MariaDBHE + ManagedWebspaceHE o-- "0..n" PostgresDBUserHE + PostgresDBUserHE o-- "0..n" PostgresDBHE + + DomainHttpSetupHE --|> UnixUserHE : assignedToAsset + + ManagedWebspaceHE --|> ManagedServerHE + + namespace Office { + class Partner { + } + + class Membership { + } + + class Debitor { + + } + } + + namespace Booking { + class Project { + +caption + +create() + } + class PrivateCloudBI { + +caption + ~resources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class CloudServerBI { + +caption + ~resources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class ManagedServerBI { + +caption + ~respources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class ManagedWebspaceBI { + +caption + ~resources = [ + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ⠀⠀+MultiOptions + ⠀⠀+Daemons + ] + + +book() + } + } + + style Project stroke:blue,stroke-width:4px + style PrivateCloudBI stroke:blue,stroke-width:4px + style CloudServerBI stroke:blue,stroke-width:4px + style ManagedServerBI stroke:blue,stroke-width:4px + style ManagedWebspaceBI stroke:blue,stroke-width:4px + + %% --------------------------------------------------------- + + namespace HostingServers { + %% separate (pseudo-) namespace just for better rendering + + class CloudServerHE { + -identifier, e.g. "vm1234" + -caption := bi.caption? + -parentAsset := parentHost + -identifier := serverName + -create() + } + class ManagedServerHE { + -identifier, e.g. "vm1234" + -caption := bi.caption? + -parentAsset := parentHost + -identifier := serverName + ~config = [ + ⠀⠀+installed Software + ] + -create() + } + } + + namespace Hosting { + class ManagedWebspaceHE { + -parentAsset := parentManagedServer + -identifier : webspaceName + +caption + + -create() + } + + class UnixUserHE { + +identifier ["xyz00-..."] + +caption + ~config = [ + ⠀⠀+SSD Soft Quota + ⠀⠀+SSD Hard Quota + ⠀⠀+HDD Soft Quota + ⠀⠀+HDD Hard Quota + ⠀⠀#shell + ⠀⠀#password + ] + + +create() + } + class DomainDNSSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class DomainHttpSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class DomainEMailSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class EMailAliasHE { + +identifier, e.g "xyz00-..." + +caption + + ~config = [ + ⠀⠀+target[] + ] + + +create() + } + class EMailAddressHE { + +identifier, e.g. "test@example.org" + +caption + ~config = [ + ⠀⠀+sub-domain + ⠀⠀+local-part + ⠀⠀+target + ] + + +create() + } + class MariaDBUserHE { + +identifier, e.g. "xyz00_mydb" + +caption + config = [ + ⠀⠀#password + ] + + +create() + } + class MariaDBHE { + +identifier, e.g. "xyz00_mydb" + +caption + ~config = [ + ⠀⠀+encoding + ] + + +create() + } + class PostgresDBUserHE { + +identifier, e.g. "xyz00_mydb" + +caption + ~config = [ + ⠀⠀#password + ] + + +create() + } + class PostgresDBHE { + +identifier, e.g. "xyz00_mydb" + +caption + + ~config = [ + ⠀⠀+encoding + ⠀⠀+extensions + ] + +create() + } + } + + style CloudServerHE stroke:orange,stroke-width:4px + style ManagedServerHE stroke:orange,stroke-width:4px + style ManagedWebspaceHE stroke:orange,stroke-width:4px + style UnixUserHE stroke:blue,stroke-width:4px + style DomainDNSSetupHE stroke:blue,stroke-width:4px + style DomainHttpSetupHE stroke:blue,stroke-width:4px + style DomainEMailSetupHE stroke:blue,stroke-width:4px + style EMailAliasHE stroke:blue,stroke-width:4px + style EMailAddressHE stroke:blue,stroke-width:4px + style MariaDBUserHE stroke:blue,stroke-width:4px + style MariaDBHE stroke:blue,stroke-width:4px + style PostgresDBUserHE stroke:blue,stroke-width:4px + style PostgresDBHE stroke:blue,stroke-width:4px + +%% -------------------------------------- + + ParentA o-- ChildA : can contain + ParentB *-- ChildB : contains + + namespace Agenda { + class ParentA { + } + class ChildA { + } + class ParentB { + } + class ChildB { + } + class CreatedByClient { + } + class CreatedAutomatically { + } + class SomeEntity { + ~patchable = [ + %% the following indentations uses two U+2800 to have effect in the rendered diagram + ⠀⠀+first + ⠀⠀+second + ] + -readOnly for client accounts + +readWrite for client accounts + #writeOnly + } + } + + style CreatedByClient stroke:blue,stroke-width:4px + style CreatedAutomatically stroke:orange,stroke-width:4px +end +``` diff --git a/doc/rbac-performance-analysis.md b/doc/rbac-performance-analysis.md new file mode 100644 index 00000000..fa80dde4 --- /dev/null +++ b/doc/rbac-performance-analysis.md @@ -0,0 +1,468 @@ +# RBAC Performance Analysis + +This describes the analysis of the legacy-data-import which took way too long, which turned out to be a problem in the RBAC-access-rights-check as well as `EntityManager.persist` creating too many SQL queries. + + +## Our Performance-Problem + +During the legacy data import for hosting assets we noticed massive performance problems. The import of about 2200 hosting-assets (IP-numbers, managed-webspaces, managed- and cloud-servers) as well as the creation of booking-items and booking-projects as well as necessary office-data entities (persons, contacts, partners, debitors, relations) **took 25 minutes**. + +Importing hosting assets up to UnixUsers and EmailAddresses even **took about 100 minutes**. + +(The office data import sometimes, but rarely, took only 10min. +We could not find a pattern, why that was the case. The impression that it had to do with too many other parallel processes, e.g. browser with BBB or IntelliJ IDEA was proved wrong, but stopping all unnecessary processes and performing the import again.) + + +## Preparation + +### Configuring PostgreSQL + +The pg_stat_statements PostgreSQL-Extension can be used to measure how long queries take and how often they are called. + +The module auto_explain can be used to automatically run EXPLAIN on long-running queries. + +To use this extension and module, we extended the PostgreSQL-Docker-image: + +```Dockerfile +FROM postgres:15.5-bookworm + +RUN apt-get update && \ + apt-get install -y postgresql-contrib && \ + apt-get clean + +COPY etc/postgresql-log-slow-queries.conf /etc/postgresql/postgresql.conf +``` + +And create an image from it: + +```sh +docker build -t postgres-with-contrib:15.5-bookworm . +``` + +Then we created a config file for PostgreSQL in `etc/postgresql-log-slow-queries.conf`: + +``` +shared_preload_libraries = 'pg_stat_statements,auto_explain' +log_min_duration_statement = 1000 +log_statement = 'all' +log_duration = on +pg_stat_statements.track = all +auto_explain.log_min_duration = '1s' # Logs queries taking longer than 1 second +auto_explain.log_analyze = on # Include actual run times +auto_explain.log_buffers = on # Include buffer usage statistics +auto_explain.log_format = 'json' # Format the log output in JSON +listen_addresses = '*' +``` + +And a Docker-Compose config in 'docker-compose.yml': + +``` +version: '3.8' + +services: + postgres: + image: postgres-with-contrib:15.5-bookworm + container_name: custom-postgres + environment: + POSTGRES_PASSWORD: password + volumes: + - /home/mi/Projekte/Hostsharing/hsadmin-ng/etc/postgresql-log-slow-queries.conf:/etc/postgresql/postgresql.conf + ports: + - "5432:5432" + command: + - bash + - -c + - > + apt-get update && + apt-get install -y postgresql-contrib && + docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf +``` + +### Activate the pg_stat_statements Extension + +The pg_stat_statements extension was activated in our Liquibase-scripts: + +``` +create extension if not exists "pg_stat_statements"; +``` + +### Running the Tweaked PostgreSQL + +Now we can run PostgreSQL with activated slow-query-logging: + +```shell +docker-compose up -d +``` + +### Running the Import + +Using an environment like this: + +```shell +export HSADMINNG_POSTGRES_JDBC_URL=jdbc:postgresql://localhost:5432/postgres +export HSADMINNG_POSTGRES_ADMIN_USERNAME=postgres +export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password +export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted +export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net +``` + +We can now run the hosting-assets-import: + +```shell +time gw-importHostingAssets +``` + +### Fetch the Query Statistics + +And afterward we can query the statistics in PostgreSQL, e.g.: + +```SQL +WITH statements AS ( + SELECT * FROM pg_stat_statements pss +) +SELECT calls, + total_exec_time::int/(60*1000) as total_mins, + mean_exec_time::int as mean_millis, + query +FROM statements +WHERE calls > 100 AND shared_blks_hit > 0 +ORDER BY total_exec_time DESC +LIMIT 16; +``` + +### Reset the Query Statistics + +```SQL +SELECT pg_stat_statements_reset(); +``` + + +## Analysis Result + +### RBAC-Access-Rights Detection query + +This CTE query was run over 4000 times during a single import and takes in total the whole execution time of the import process: + +```SQL +WITH RECURSIVE grants AS ( + SELECT descendantUuid, ascendantUuid, $5 AS level + FROM RbacGrants + WHERE assumed + AND ascendantUuid = any(subjectIds) + UNION ALL + SELECT g.descendantUuid, g.ascendantUuid, grants.level + $6 AS level + FROM RbacGrants g + INNER JOIN grants ON grants.descendantUuid = g.ascendantUuid + WHERE g.assumed +), +granted AS ( + SELECT DISTINCT descendantUuid + FROM grants +) +SELECT DISTINCT perm.objectUuid + FROM granted + JOIN RbacPermission perm ON granted.descendantUuid = perm.uuid + JOIN RbacObject obj ON obj.uuid = perm.objectUuid + WHERE (requiredOp = $7 OR perm.op = requiredOp) + AND obj.objectTable = forObjectTable + LIMIT maxObjects+$8 +``` + +That query is used to determine access rights of the currently active RBAC-subject(s). + +We used `EXPLAIN` with a concrete version (parameters substituted with real values) of that query and got this result: + +``` +QUERY PLAN +Limit (cost=6549.08..6549.35 rows=54 width=16) + CTE grants + -> Recursive Union (cost=4.32..5845.97 rows=1103 width=36) + -> Bitmap Heap Scan on rbacgrants (cost=4.32..15.84 rows=3 width=36) + Recheck Cond: (ascendantuuid = ANY ('{ad1133dc-fbb7-43c9-8c20-0da3f89a2388}'::uuid[])) + Filter: assumed + -> Bitmap Index Scan on rbacgrants_ascendantuuid_idx (cost=0.00..4.32 rows=3 width=0) + Index Cond: (ascendantuuid = ANY ('{ad1133dc-fbb7-43c9-8c20-0da3f89a2388}'::uuid[])) + -> Nested Loop (cost=0.29..580.81 rows=110 width=36) + -> WorkTable Scan on grants grants_1 (cost=0.00..0.60 rows=30 width=20) + -> Index Scan using rbacgrants_ascendantuuid_idx on rbacgrants g (cost=0.29..19.29 rows=4 width=32) + Index Cond: (ascendantuuid = grants_1.descendantuuid) + Filter: assumed + -> Unique (cost=703.11..703.38 rows=54 width=16) + -> Sort (cost=703.11..703.25 rows=54 width=16) + Sort Key: perm.objectuuid + -> Nested Loop (cost=31.60..701.56 rows=54 width=16) + -> Hash Join (cost=31.32..638.78 rows=200 width=16) + Hash Cond: (perm.uuid = grants.descendantuuid) + -> Seq Scan on rbacpermission perm (cost=0.00..532.92 rows=28392 width=32) + -> Hash (cost=28.82..28.82 rows=200 width=16) + -> HashAggregate (cost=24.82..26.82 rows=200 width=16) + Group Key: grants.descendantuuid + -> CTE Scan on grants (cost=0.00..22.06 rows=1103 width=16) + -> Index Only Scan using rbacobject_objecttable_uuid_key on rbacobject obj (cost=0.28..0.31 rows=1 width=16) + Index Cond: ((objecttable = 'hs_hosting_asset'::text) AND (uuid = perm.objectuuid)) +``` + +### Office-Relation-Query + +```SQL +SELECT hore1_0.uuid,a1_0.uuid,a1_0.familyname,a1_0.givenname,a1_0.persontype,a1_0.salutation,a1_0.title,a1_0.tradename,a1_0.version,c1_0.uuid,c1_0.caption,c1_0.emailaddresses,c1_0.phonenumbers,c1_0.postaladdress,c1_0.version,h1_0.uuid,h1_0.familyname,h1_0.givenname,h1_0.persontype,h1_0.salutation,h1_0.title,h1_0.tradename,h1_0.version,hore1_0.mark,hore1_0.type,hore1_0.version + FROM hs_office_relation_rv hore1_0 + LEFT JOIN hs_office_person_rv a1_0 ON a1_0.uuid=hore1_0.anchoruuid + LEFT JOIN hs_office_contact_rv c1_0 ON c1_0.uuid=hore1_0.contactuuid + LEFT JOIN hs_office_person_rv h1_0 ON h1_0.uuid=hore1_0.holderuuid + WHERE hore1_0.uuid=$1 +``` + +That query on the `hs_office_relation_rv`-table joins the three references anchor-person, holder-person and contact. + + +### Total-Query-Time > Total-Import-Runtime + +That both queries total up to more than the runtime of the import-process is most likely due to internal parallel query processing. + + +## Attempts to Mitigate the Problem + +### VACUUM ANALYZE + +In the middle of the import, we updated the PostgreSQL statistics to recalibrate the query optimizer: + +```SQL +VACUUM ANALYZE; +``` + +This did not improve the performance. + + +### Improving Joins + Indexes + +We were suspicious about the sequential scan over all `rbacpermission` rows which was done by PostgreSQL to execute a HashJoin strategy. Turning off that strategy by + +```SQL +ALTER FUNCTION queryAccessibleObjectUuidsOfSubjectIds SET enable_hashjoin = off; +``` + +did not improve the performance though. The HashJoin was actually still applied, but no full table scan anymore: + +``` +[...] + QUERY PLAN + -> Hash Join (cost=36.02..40.78 rows=1 width=16) + Hash Cond: (grants.descendantuuid = perm.uuid) + -> HashAggregate (cost=13.32..15.32 rows=200 width=16) + Group Key: grants.descendantuuid + -> CTE Scan on grants (cost=0.00..11.84 rows=592 width=16) +[...] +``` + +The HashJoin strategy could be great if the hash-map could be kept for multiple invocations. But during an import process, of course, there are always new rows in the underlying table and the hash-map would be outdated immediately. + +Also creating indexes which should suppor the RBAC query, like the following, did not improve performance: + +```SQL +create index on RbacPermission (objectUuid, op); +create index on RbacPermission (opTableName, op); +``` + +### LAZY loading for Relation.anchorPerson/.holderPerson/ + +At this point, the import took 21mins with these statistics: + +| query | calls | total_m | mean_ms | +|-------|-------|---------|---------| +| select hore1_0.uuid,a1_0.uuid,a1_0.familyname,a1_0.givenname,a1_0.persontype,a1_0.salutation,a1_0.title,a1_0.tradename,a1_0.version,c1_0.uuid,c1_0.caption,c1_0.emailaddresses,c1_0.phonenumbers,c1_0.postaladdress, c1_0.version,h1_0.uuid,h1_0.familyname,h1_0.givenname,h1_0.persontype,h1_0.salutation,h1_0.title,h1_0.tradename,h1_0.version,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office_relation_rv hore1_0 left join public.hs_office_person_rv a1_0 on a1_0.uuid=hore1_0.anchoruuid left join public.hs_office_contact_rv c1_0 on c1_0.uuid=hore1_0.contactuuid left join public.hs_office_person_rv h1_0 on h1_0.uuid=hore1_0.holderuuid where hore1_0.uuid=$1 | 517 | 11 | 1282 | +| select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office_person_rv hope1_0 where hope1_0.uuid=$1 | 973 | 4 | 254 | +| select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office_contact_rv hoce1_0 where hoce1_0.uuid=$1 | 973 | 4 | 253 | +| call grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) | 31316 | 0 | 1 | +| call buildRbacSystemForHsHostingAsset(NEW) | 2258 | 0 | 7 | +| select * from isGranted(array[granteeId], grantedId) | 44613 | 0 | 0 | +| insert into public.hs_hosting_asset_rv (alarmcontactuuid,assignedtoassetuuid,bookingitemuuid,caption,config,identifier,parentassetuuid,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) | 2207 | 0 | 7 | +| insert into hs_hosting_asset (alarmcontactuuid, version, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, config, uuid, identifier, caption) values (new.alarmcontactuuid, new. version, new. bookingitemuuid, new. type, new. parentassetuuid, new. assignedtoassetuuid, new. config, new. uuid, new. identifier, new. caption) returning * | 2207 | 0 | 7 | +| insert into public.hs_office_relation_rv (anchoruuid,contactuuid,holderuuid,mark,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7) | 1261 | 0 | 9 | +| insert into hs_office_relation (uuid, version, anchoruuid, holderuuid, contactuuid, type, mark) values (new.uuid, new. version, new. anchoruuid, new. holderuuid, new. contactuuid, new. type, new. mark) returning * | 1261 | 0 | 9 | +| call buildRbacSystemForHsOfficeRelation(NEW) | 1276 | 0 | 8 | +| with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select ""grant"".descendantUuid, ""grant"".ascendantUuid from RbacGrants ""grant"" inner join grants recur on recur.ascendantUuid = ""grant"".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) | 47540 | 0 | 0 | +| insert into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing" | 40472 | 0 | 0 | +| insert into public.hs_booking_item_rv (caption,parentitemuuid,projectuuid,resources,type,validity,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8) | 926 | 0 | 7 | +| insert into hs_booking_item (resources, version, projectuuid, type, parentitemuuid, validity, uuid, caption) values (new.resources, new. version, new. projectuuid, new. type, new. parentitemuuid, new. validity, new. uuid, new. caption) returning * | 926 | 0 | 7 | + + +The slowest query now was fetching Relations joined with Contact, Anchor-Person and Holder-Person, for all tables using the restricted (RBAC) views (_rv). + +We changed these mappings from `EAGER` (default) to `LAZY` to `@ManyToOne(fetch = FetchType.LAZY)` and got this result: + +:::small +| query | calls | total (min) | mean (ms) | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------------|----------| +| select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office_person_rv hope1_0 where hope1_0.uuid=$1 | 1015 | 4 | 238 | +| select hore1_0.uuid,hore1_0.anchoruuid,hore1_0.contactuuid,hore1_0.holderuuid,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office_relation_rv hore1_0 where hore1_0.uuid=$1 | 517 | 4 | 439 | +| select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office_contact_rv hoce1_0 where hoce1_0.uuid=$1 | 497 | 2 | 213 | +| call grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) | 31316 | 0 | 1 | +| select * from isGranted(array[granteeId], grantedId) | 44613 | 0 | 0 | +| call buildRbacSystemForHsHostingAsset(NEW) | 2258 | 0 | 7 | +| insert into public.hs_hosting_asset_rv (alarmcontactuuid,assignedtoassetuuid,bookingitemuuid,caption,config,identifier,parentassetuuid,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) | 2207 | 0 | 7 | +| insert into hs_hosting_asset (alarmcontactuuid, version, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, config, uuid, identifier, caption) values (new.alarmcontactuuid, new. version, new. bookingitemuuid, new. type, new. parentassetuuid, new. assignedtoassetuuid, new. config, new. uuid, new. identifier, new. caption) returning * | 2207 | 0 | 7 | +| with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select ""grant"".descendantUuid, ""grant"".ascendantUuid from RbacGrants ""grant"" inner join grants recur on recur.ascendantUuid = ""grant"".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) | 47538 | 0 | 0 | + insert into public.hs_office_relation_rv (anchoruuid,contactuuid,holderuuid,mark,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7) | 1261 | 0 | 8 | +| insert into hs_office_relation (uuid, version, anchoruuid, holderuuid, contactuuid, type, mark) values (new.uuid, new. version, new. anchoruuid, new. holderuuid, new. contactuuid, new. type, new. mark) returning * | 1261 | 0 | 8 | +| call buildRbacSystemForHsOfficeRelation(NEW) | 1276 | 0 | 7 | +| insert into public.hs_booking_item_rv (caption,parentitemuuid,projectuuid,resources,type,validity,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8) | 926 | 0 | 7 | +| insert into hs_booking_item (resources, version, projectuuid, type, parentitemuuid, validity, uuid, caption) values (new.resources, new. version, new. projectuuid, new. type, new. parentitemuuid, new. validity, new. uuid, new. caption) returning * | 926 | 0 | 7 | + insert into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing | 40472 | 0 | 0 | + +Now, finally, the total runtime of the import was down to 12 minutes. This is repeatable, where originally, the import took about 25mins in most cases and just rarely - and for unknown reasons - 10min. + +### Importing UnixUser and EmailAlias Assets + +But once UnixUser and EmailAlias assets got added to the import, the total time went up to about 110min. + +This was not acceptable, especially not, considering that domains, email-addresses and database-assets are almost 10 times that number and thus the import would go up to over 1100min which is 20 hours. + +In a first step, a `HsHostingAssetRawEntity` was created, mapped to the raw table (hs_hosting_asset) not to the RBAC-view (hs_hosting_asset_rv). Unfortunately we did not keep measurements, but that was only part of the problem anyway. + +The main problem was, that there is something strange with persisting (`EntityManager.persist`) for EmailAlias assets. Where importing UnixUsers was mostly slow due to RBAC SELECT-permission checks, persisting EmailAliases suddenly created about a million (in numbers 1.000.000) SQL UPDATE statements after the INSERT, all with the same data, just increased version number (used for optimistic locking). We were not able to figure out why this happened. + +Keep in mind, it's the same table with the same RBAC-triggers, just a different value in the type column. + +Once `EntityManager.persist` was replaced by an explicit SQL INSERT - just for `HsHostingAssetRawEntity`, the total time was down to 17min. Thus importing the UnixUsers and EmailAliases took just 5min, which is an acceptable result. The total import of all HostingAssets is now estimated to about 1 hour (on my developer laptop). + +Now, the longest running queries are these: + +| No.| calls | total_m | mean_ms | query | +|---:|---------|--------:|--------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | 13.093 | 4 | 21 | insert into hs_hosting_asset( uuid, type, bookingitemuuid, parentassetuuid, assignedtoassetuuid, alarmcontactuuid, identifier, caption, config, version) values ( $1, $2, $3, $4, $5, $6, $7, $8, cast($9 as jsonb), $10) | +| 2 | 517 | 4 | 502 | select hore1_0.uuid,hore1_0.anchoruuid,hore1_0.contactuuid,hore1_0.holderuuid,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office_relation_rv hore1_0 where hore1_0.uuid=$1 | +| 3 | 13.144 | 4 | 21 | call buildRbacSystemForHsHostingAsset(NEW) | +| 4 | 96.632 | 3 | 2 | call grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) | +| 5 | 120.815 | 3 | 2 | select * from isGranted(array[granteeId], grantedId) | +| 6 | 123.740 | 3 | 2 | with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select "grant".descendantUuid, "grant".ascendantUuid from RbacGrants "grant" inner join grants recur on recur.ascendantUuid = "grant".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) | +| 7 | 497 | 2 | 259 | select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office_contact_rv hoce1_0 where hoce1_0.uuid=$1 | +| 8 | 497 | 2 | 255 | select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office_person_rv hope1_0 where hope1_0.uuid=$1 | +| 9 | 13.144 | 1 | 8 | SELECT createRoleWithGrants( hsHostingAssetTENANT(NEW), permissions => array[$7], incomingSuperRoles => array[ hsHostingAssetAGENT(NEW), hsOfficeContactADMIN(newAlarmContact)], outgoingSubRoles => array[ hsBookingItemTENANT(newBookingItem), hsHostingAssetTENANT(newParentAsset)] ) | +| 10 | 13.144 | 1 | 5 | SELECT createRoleWithGrants( hsHostingAssetADMIN(NEW), permissions => array[$7], incomingSuperRoles => array[ hsBookingItemAGENT(newBookingItem), hsHostingAssetAGENT(newParentAsset), hsHostingAssetOWNER(NEW)] ) | + +That the `INSERT into hs_hosting_asset` (No. 1) takes up the most time, seems to be normal, and 21ms for each call is also fine. + +It seems that the trigger effects (eg. No. 3 and No. 4) are included in the measure for the causing INSERT, otherwise summing up the totals would exceed the actual total time of the whole import. And it was to be expected that building the RBAC rules for new business objects takes most of the time. + +In production, the `SELECT ... FROM hs_office_relation_rv` (No. 2) with about 0.5 seconds could still be a problem. But once we apply the improvements from the hosting asset area also to the office area, this should not be a problem for the import anymore. + + +## Further Options To Explore + +1. Instead of separate SQL INSERT statements, we could try bulk INSERT. +2. We could use the SQL INSERT method for all entity-classes, or at least for all which have high row counts. +3. For the production code, we could use raw-entities for referenced entities, here usually RBAC SELECT permission is given anyway. + + +## The Problematically Huge Join + +The origin problem was the expensive RBAC check for many SELECT queries. +This consists of two parts: + +1. The recursive CTE query to determine which object's UUIDs are visible for the current subject. + This query itself takes currently about 250ms thus is no problem by itself as long as we only need it once per request. +2. Joining the result from 1. with the result if a business query. + The performance of the business query itself is no problem, for the join see the following explanations. + +Superusers can see all objects (currently already over 90.000) +and even high level roles of customers with many hosting assets can see several thousand objects. +This is the one side of that problematic join. + +The other side of that problematic is the result of the business query. +For example if a user wants to select all of their e-mail-addresses, that might easily half of the visible objects. + +Thus, we would have a join of for example 5.000 x 2.500 rows, which is going to be slow. +As there are currently about 84.000 objects are hosting assets and 33.000 e-mail-addresses in our system, +for a superuser we would even run into an 84.0000 x 33.0000 join. + +We found some solution approaches: + +1. Getting rid of the `rbacrole` and `rbacpermission` table and only having implicit roles with implicit grants (OWNER->ADMIN->AGENT->TENENT->REFERRER) by comparison of ordered enum values and fixed permission assignments (e.g. OWENER->DELETE, ADMIN->UPDATE etc.). We could also get rid of the table `rbacreferece` if we enter users as business objects. + + This should dramatically reduce the size of the table `rbackgrant` as well as the recusion levels. + + But since we only apply this query once for each business query, that would only improve performance once we have way more objects in our system, but does not help our current problem. + + It's quite some effort to implement even just a prototype, so we did not further explore this idea. + +2. Adding the object type to the table `rbacObject` to reduce the size of the result of the recursive CTE query. + + See chapter below. + +3. Inverting the recursion of the CTE-query, combined with the type condition. + + Instead of starting the recursion with `currentsubjectsuuids()`, + we could start it with the target table name and row-type, + then recurse down to the `currentsubjectsuuids()`. + + In the end, we need the object UUIDs, though. + But if we start with the join of `rbacObject` with `rbacPermission`, + we need to forward the object UUIDs through the whole recursion. + + This idea was not yet further explored. + + +### Adding The Object Type To The Table `rbacObject` + +This optimization idea came from Michael Hierweck and was promising. +The idea is to reduce the size of the result of the recursive CTE query and maybe even speed up that query itself. + +To evaluate this, I added a type column to the `rbacObject` table, initially as an enum hsHostingAssetType. Then I entered the type there for all rows from hs_hosting_asset. This means that 83,886 of 92,545 rows in `rbacobject` have a type set, leaving 8,659 without. + +If we do this for other types (we currently have 1,271 relations and 927 booking items), it gets more complicated because they are different enum types. As varchar(16), we could lose performance again due to the higher storage space requirements. + +But the performance gained is not particularly high anyway. +See the average seconds per recursive CTE select as role 'hs_hosting_asset:defaultproject:ADMIN', +joined with business query for all `'EMAIL_ADDRESSES'`: + +| | D-1000000-hsh | D-1000300-mih | +|-----------------------------------------------------|------------------|---------------| +| currently (without type comparision in rbacobject): | ~3.30 - ~3.49 | ~0.23 | +| optimized (with type comparision in rbacobject): | ~2.99 - ~3.08 | ~0.21 | + +As you can see, the query is no problem at all for normal customers (in the example, yours truly). With Hostsharing (D-1000000-hsh) it is quite slow. + +Luckily this experiment also shows that it's not a big problem, having all hosting assets in the same database table. + +Implementing this approach would be a bit difficult anyway, because we would need to transfer the type query parameter into the definition of the restricted view. We have not even the slightest idea how this could be done. + +See the related queries in [recursive-cte-experiments-for-accessible-uuids.sql](../sql/recursive-cte-experiments-for-accessible-uuids.sql). They might have changed independently since this document was written, but you can still check out the old version from git. + +### Rearranging the Parts of the CTE-Query + +I also moved the function call which determines into its own WITH-section, with no improvement. + +Experimentally I moved the business condition into the CTE SELECT, also with no improvement. + +Such rearrangements seem to be successfully done by the PostgreSQL query optimizer. + +## Summary + +### What we did Achieve? + +In a first step, the total import runtime for office entities was reduced from about 25min to about 10min. + +In a second step, we reduced the import of booking- and hosting-assets from about 100min (not counting the required office entities) to 5min. + +### What did not Help? + +Rearranging the CTE query by extracting parts into WITH-clauses did not improve the performance. + +Surprisingly little performance gain (<10% improvement) came from reducing the result of the CTE query by moving the hosting asset type into RBAC-system and using it in the inner SELECT query instead of in the outer SELECT query of the application side. + +### What did Help? + +Merging the recursive CTE query to determine the RBAC SELECT-permission, made it more clear which business-queries take the time. + +Avoiding EAGER-loading where not necessary, reduced the total runtime of the import to about the half. + +The major improvement came from using direct INSERT statements, which avoided some SELECT statements unnecessarily generated by the EntityManager and also completely bypassed the RBAC SELECT permission checks. + +### What Still Has To Be Done? + +Where this performance analysis was mostly helping the performance of the legacy data import, we still need measures and improvements for the productive code. + +For sure, using more LAZY-loading also helps in the production code. For some more ideas see section _Further Options To Explore_. + + diff --git a/doc/rbac.md b/doc/rbac.md index 06a6ee7e..662bed29 100644 --- a/doc/rbac.md +++ b/doc/rbac.md @@ -1,6 +1,6 @@ ## *hsadmin-ng*'s Role-Based-Access-Management (RBAC) -The requirements of *hsadmin-ng* include table-m row- and column-level-security for read and write access to business-objects. +The requirements of *hsadmin-ng* include table-, row- and column-level-security for read and write access to business-objects. More precisely, any access has to be controlled according to given rules depending on the accessing users, their roles and the accessed business-object. Further, roles and business-objects are hierarchical. @@ -11,7 +11,7 @@ Our implementation is based on Role-Based-Access-Management (RBAC) in conjunctio As far as possible, we are using the same terms as defined in the RBAC standard, for our function names though, we chose more expressive names. In RBAC, subjects can be assigned to roles, roles can be hierarchical and eventually have assigned permissions. -A permission allows a specific operation (e.g. view or edit) on a specific (business-) object. +A permission allows a specific operation (e.g. SELECT or UPDATE) on a specific (business-) object. You can find the entity structure as a UML class diagram as follows: @@ -101,13 +101,12 @@ package RBAC { RbacPermission *-- RbacObject enum RbacOperation { - add-package - add-domain - add-domain + INSERT:package + INSERT:domain ... - view - edit - delete + SELECT + UPDATE + DELETE } entity RbacObject { @@ -172,11 +171,10 @@ An *RbacPermission* allows a specific *RbacOperation* on a specific *RbacObject* An *RbacOperation* determines, what an *RbacPermission* allows to do. It can be one of: -- **'add-...'** - permits creating new instances of specific entity types underneath the object specified by the permission, e.g. "add-package" -- **'view'** - permits reading the contents of the object specified by the permission -- **'edit'** - change the contents of the object specified by the permission -- **'delete'** - delete the object specified by the permission -- **'\*'** +- **'INSERT'** - permits inserting new rows related to the row, to which the permission belongs, in the table which is specified an extra column, includes 'SELECT' +- **'SELECT'** - permits selecting the row specified by the permission, is included in all other permissions +- **'UPDATE'** - permits updating (only the updatable columns of) the row specified by the permission, includes 'SELECT' +- **'DELETE'** - permits deleting the row specified by the permission, includes 'SELECT' This list is extensible according to the needs of the access rule system. @@ -198,56 +196,60 @@ E.g. if a new package is added, the admin-role of the related customer has to be There can be global roles like 'administrators'. Most roles, though, are specific for certain business-objects and automatically generated as such: - business-object-table#business-object-name.relative-role + business-object-table#business-object-name.role-stereotype Where *business-object-table* is the name of the SQL table of the business object (e.g *customer* or 'package'), *business-object-name* is generated from an immutable business key(e.g. a prefix like 'xyz' or 'xyz00') -and the *relative-role*' describes the role relative to the referenced business-object as follows: +and the *role-stereotype* describes a role relative to a referenced business-object as follows: #### owner The owner-role is granted to the subject which created the business object. -E.g. for a new *customer* it would be granted to 'administrators' and for a new *package* to the 'customer#...admin'. +E.g. for a new *customer* it would be granted to 'administrators' and for a new *package* to the 'customer#...:ADMIN'. Whoever has the owner-role assigned can do everything with the related business-object, including deleting (or deactivating) it. -In most cases, the permissions to other operations than 'delete' are granted through the 'admin' role. +In most cases, the permissions to other operations than 'DELETE' are granted through the 'admin' role. By this, all roles ob sub-objects, which are assigned to the 'admin' role, are also granted to the 'owner'. -#### admin +#### ADMIN The admin-role is granted to a role of those subjects who manage the business object. E.g. a 'package' is manged by the admin of the customer. -Whoever has the admin-role assigned, can usually edit the related business-object but not deleting (or deactivating) it. +Whoever has the admin-role assigned, can usually update the related business-object but not delete (or deactivating) it. -The admin-role also comprises lesser roles, through which the view-permission is granted. +The admin-role also comprises lesser roles, through which the SELECT-permission is granted. -#### agent +#### AGENT The agent-role is not used in the examples of this document, because it's for more complex cases. -It's usually granted to those roles and users who represent the related business-object, but are not allowed to edit it. +It's usually granted to those roles and users who represent the related business-object, but are not allowed to update it. Other than the tenant-role, it usually offers broader visibility of sub-business-objects (joined entities). E.g. a package-admin is allowed to see the related debitor-business-object, but not its banking data. -#### tenant +#### TENANT -The tenant-role is granted to everybody who needs to be able to view the business-object and (probably some) related business-objects. +The tenant-role is granted to everybody who needs to be able to select the business-object and (probably some) related business-objects. Usually all owners, admins and tenants of sub-objects get this role granted. -Some business-objects only have very limited data directly in the main business-object and store more sensitive data in special sub-objects (e.g. 'customer-details') to which tenants of sub-objects of the main-object (e.g. package admins) do not get view permission. +Some business-objects only have very limited data directly in the main business-object and store more sensitive data in special sub-objects (e.g. 'customer-details') to which tenants of sub-objects of the main-object (e.g. package admins) do not get SELECT permission. -#### guest +#### GUEST + +(Deprecated) + +#### REFERRER Like the agent-role, the guest-role too is not used in the examples of this document, because it's for more complex cases. -If the guest-role exists, the view-permission is granted to it, instead of to the tenant-role. -Other than the tenant-role, the guest-roles does never grant any roles of related objects. +If the referrer-role exists, the SELECT-permission is granted to it, instead of to the tenant-role. +Other than the tenant-role, the referrer-roles does never grant any roles of related objects. -Also, if the guest-role exists, the tenant-role receives the view-permission through the guest-role. +Also, if the referrer-role exists, the tenant-role receives the SELECT-permission through the referrer-role. ### Referenced Business Objects and Role-Depreciation @@ -263,7 +265,7 @@ The admin-role of one object could be granted visibility to another object throu But not in all cases role-depreciation takes place. E.g. often a tenant-role is granted another tenant-role, -because it should be again allowed to view sub-objects. +because it should be again allowed to select sub-objects. The same for the agent-role, often it is granted another agent-role. @@ -297,14 +299,14 @@ package RbacRoles { RbacUsers -[hidden]> RbacRoles package RbacPermissions { - object PermCustXyz_View - object PermCustXyz_Edit - object PermCustXyz_Delete - object PermCustXyz_AddPackage - object PermPackXyz00_View - object PermPackXyz00_Edit - object PermPackXyz00_Delete - object PermPackXyz00_AddUser + object PermCustXyz_SELECT + object PermCustXyz_UPDATE + object PermCustXyz_DELETE + object PermCustXyz_INSERT:Package + object PermPackXyz00_SELECT + object PermPackXyz00_EDIT + object PermPackXyz00_DELETE + object PermPackXyz00_INSERT:USER } RbacRoles -[hidden]> RbacPermissions @@ -322,23 +324,23 @@ RoleAdministrators o..> RoleCustXyz_Owner RoleCustXyz_Owner o-> RoleCustXyz_Admin RoleCustXyz_Admin o-> RolePackXyz00_Owner -RoleCustXyz_Owner o--> PermCustXyz_Edit -RoleCustXyz_Owner o--> PermCustXyz_Delete -RoleCustXyz_Admin o--> PermCustXyz_View -RoleCustXyz_Admin o--> PermCustXyz_AddPackage -RolePackXyz00_Owner o--> PermPackXyz00_View -RolePackXyz00_Owner o--> PermPackXyz00_Edit -RolePackXyz00_Owner o--> PermPackXyz00_Delete -RolePackXyz00_Owner o--> PermPackXyz00_AddUser +RoleCustXyz_Owner o--> PermCustXyz_UPDATE +RoleCustXyz_Owner o--> PermCustXyz_DELETE +RoleCustXyz_Admin o--> PermCustXyz_SELECT +RoleCustXyz_Admin o--> PermCustXyz_INSERT:Package +RolePackXyz00_Owner o--> PermPackXyz00_SELECT +RolePackXyz00_Owner o--> PermPackXyz00_UPDATE +RolePackXyz00_Owner o--> PermPackXyz00_DELETE +RolePackXyz00_Owner o--> PermPackXyz00_INSERT:User -PermCustXyz_View o--> CustXyz -PermCustXyz_Edit o--> CustXyz -PermCustXyz_Delete o--> CustXyz -PermCustXyz_AddPackage o--> CustXyz -PermPackXyz00_View o--> PackXyz00 -PermPackXyz00_Edit o--> PackXyz00 -PermPackXyz00_Delete o--> PackXyz00 -PermPackXyz00_AddUser o--> PackXyz00 +PermCustXyz_SELECT o--> CustXyz +PermCustXyz_UPDATE o--> CustXyz +PermCustXyz_DELETE o--> CustXyz +PermCustXyz_INSERT:Package o--> CustXyz +PermPackXyz00_SELECT o--> PackXyz00 +PermPackXyz00_UPDATE o--> PackXyz00 +PermPackXyz00_DELETE o--> PackXyz00 +PermPackXyz00_INSERT:User o--> PackXyz00 @enduml ``` @@ -353,12 +355,12 @@ To support the RBAC system, for each business-object-table, some more artifacts Not yet implemented, but planned are these actions: -- an `ON DELETE ... DO INSTEAD` rule to allow `SQL DELETE` if applicable for the business-object-table and the user has 'delete' permission, -- an `ON UPDATE ... DO INSTEAD` rule to allow `SQL UPDATE` if the user has 'edit' right, -- an `ON INSERT ... DO INSTEAD` rule to allow `SQL INSERT` if the user has 'add-..' right to the parent-business-object. +- an `ON DELETE ... DO INSTEAD` rule to allow `SQL DELETE` if applicable for the business-object-table and the user has 'DELETE' permission, +- an `ON UPDATE ... DO INSTEAD` rule to allow `SQL UPDATE` if the user has 'UPDATE' right, +- an `ON INSERT ... DO INSTEAD` rule to allow `SQL INSERT` if the user has the 'INSERT' right for the parent-business-object. The restricted view takes the current user from a session property and applies the hierarchy of its roles all the way down to the permissions related to the respective business-object-table. -This way, each user can only view the data they have 'view'-permission for, only create those they have 'add-...'-permission, only update those they have 'edit'- and only delete those they have 'delete'-permission to. +This way, each user can only select the data they have 'SELECT'-permission for, only create those they have 'add-...'-permission, only update those they have 'UPDATE'- and only delete those they have 'DELETE'-permission to. ### Current User @@ -374,7 +376,7 @@ That user is also used for historicization and audit log, but which is a differe If the session variable `hsadminng.assumedRoles` is set to a non-empty value, its content is interpreted as a list of semicolon-separated role names. Example: - SET LOCAL hsadminng.assumedRoles = 'customer#aab.admin;customer#aac.admin'; + SET LOCAL hsadminng.assumedRoles = 'customer#aab:admin;customer#aac:admin'; In this case, not the current user but the assumed roles are used as a starting point for any further queries. Roles which are not granted to the current user, directly or indirectly, cannot be assumed. @@ -387,7 +389,7 @@ A full example is shown here: BEGIN TRANSACTION; SET SESSION SESSION AUTHORIZATION restricted; SET LOCAL hsadminng.currentUser = 'mike@hostsharing.net'; - SET LOCAL hsadminng.assumedRoles = 'customer#aab.admin;customer#aac.admin'; + SET LOCAL hsadminng.assumedRoles = 'customer#aab:admin;customer#aac:admin'; SELECT c.prefix, p.name as "package", ema.localPart || '@' || dom.name as "email-address" FROM emailaddress_rv ema @@ -458,26 +460,26 @@ allow_mixing entity "BObj customer#xyz" as boCustXyz together { - entity "Perm customer#xyz *" as permCustomerXyzAll - permCustomerXyzAll --> boCustXyz + entity "Perm customer#xyz *" as permCustomerXyzDELETE + permCustomerXyzDELETE --> boCustXyz - entity "Perm customer#xyz add-package" as permCustomerXyzAddPack - permCustomerXyzAddPack --> boCustXyz + entity "Perm customer#xyz INSERT:package" as permCustomerXyzINSERT:package + permCustomerXyzINSERT:package --> boCustXyz - entity "Perm customer#xyz view" as permCustomerXyzView - permCustomerXyzView --> boCustXyz + entity "Perm customer#xyz SELECT" as permCustomerXyzSELECT + permCustomerXyzSELECT--> boCustXyz } -entity "Role customer#xyz.tenant" as roleCustXyzTenant -roleCustXyzTenant --> permCustomerXyzView +entity "Role customer#xyz:TENANT" as roleCustXyzTenant +roleCustXyzTenant --> permCustomerXyzSELECT -entity "Role customer#xyz.admin" as roleCustXyzAdmin +entity "Role customer#xyz:ADMIN" as roleCustXyzAdmin roleCustXyzAdmin --> roleCustXyzTenant -roleCustXyzAdmin --> permCustomerXyzAddPack +roleCustXyzAdmin --> permCustomerXyzINSERT:package -entity "Role customer#xyz.owner" as roleCustXyzOwner +entity "Role customer#xyz:OWNER" as roleCustXyzOwner roleCustXyzOwner ..> roleCustXyzAdmin -roleCustXyzOwner --> permCustomerXyzAll +roleCustXyzOwner --> permCustomerXyzDELETE actor "Customer XYZ Admin" as actorCustXyzAdmin actorCustXyzAdmin --> roleCustXyzAdmin @@ -487,13 +489,11 @@ roleAdmins --> roleCustXyzOwner actor "Any Hostmaster" as actorHostmaster actorHostmaster --> roleAdmins - - @enduml ``` As you can see, there something special: -From the 'Role customer#xyz.owner' to the 'Role customer#xyz.admin' there is a dashed line, whereas all other lines are solid lines. +From the 'Role customer#xyz:OWNER' to the 'Role customer#xyz:admin' there is a dashed line, whereas all other lines are solid lines. Solid lines means, that one role is granted to another and automatically assumed in all queries to the restricted views. The dashed line means that one role is granted to another but not automatically assumed in queries to the restricted views. @@ -527,36 +527,36 @@ allow_mixing entity "BObj package#xyz00" as boPacXyz00 together { - entity "Perm package#xyz00 *" as permPackageXyzAll - permPackageXyzAll --> boPacXyz00 + entity "Perm package#xyz00 *" as permPackageXyzDELETE + permPackageXyzDELETE --> boPacXyz00 - entity "Perm package#xyz00 add-domain" as permPacXyz00AddUser - permPacXyz00AddUser --> boPacXyz00 + entity "Perm package#xyz00 INSERT:domain" as permPacXyz00INSERT:user + permPacXyz00INSERT:user --> boPacXyz00 - entity "Perm package#xyz00 edit" as permPacXyz00Edit - permPacXyz00Edit --> boPacXyz00 + entity "Perm package#xyz00 UPDATE" as permPacXyz00UPDATE + permPacXyz00UPDATE --> boPacXyz00 - entity "Perm package#xyz00 view" as permPacXyz00View - permPacXyz00View --> boPacXyz00 + entity "Perm package#xyz00 SELECT" as permPacXyz00SELECT + permPacXyz00SELECT --> boPacXyz00 } package { - entity "Role customer#xyz.tenant" as roleCustXyzTenant - entity "Role customer#xyz.admin" as roleCustXyzAdmin - entity "Role customer#xyz.owner" as roleCustXyzOwner + entity "Role customer#xyz:TENANT" as roleCustXyzTenant + entity "Role customer#xyz:ADMIN" as roleCustXyzAdmin + entity "Role customer#xyz:OWNER" as roleCustXyzOwner } package { - entity "Role package#xyz00.owner" as rolePacXyz00Owner - entity "Role package#xyz00.admin" as rolePacXyz00Admin - entity "Role package#xyz00.tenant" as rolePacXyz00Tenant + entity "Role package#xyz00:OWNER" as rolePacXyz00Owner + entity "Role package#xyz00:ADMIN" as rolePacXyz00Admin + entity "Role package#xyz00:TENANT" as rolePacXyz00Tenant } -rolePacXyz00Tenant --> permPacXyz00View +rolePacXyz00Tenant --> permPacXyz00SELECT rolePacXyz00Tenant --> roleCustXyzTenant rolePacXyz00Owner --> rolePacXyz00Admin -rolePacXyz00Owner --> permPackageXyzAll +rolePacXyz00Owner --> permPackageXyzDELETE roleCustXyzAdmin --> rolePacXyz00Owner roleCustXyzAdmin --> roleCustXyzTenant @@ -564,8 +564,8 @@ roleCustXyzAdmin --> roleCustXyzTenant roleCustXyzOwner ..> roleCustXyzAdmin rolePacXyz00Admin --> rolePacXyz00Tenant -rolePacXyz00Admin --> permPacXyz00AddUser -rolePacXyz00Admin --> permPacXyz00Edit +rolePacXyz00Admin --> permPacXyz00INSERT:user +rolePacXyz00Admin --> permPacXyz00UPDATE actor "Package XYZ00 Admin" as actorPacXyzAdmin actorPacXyzAdmin -l-> rolePacXyz00Admin @@ -624,10 +624,10 @@ Let's have a look at the two view queries: WHERE target.uuid IN ( SELECT uuid FROM queryAccessibleObjectUuidsOfSubjectIds( - 'view', 'customer', currentSubjectsUuids())); + 'SELECT, 'customer', currentSubjectsUuids())); This view should be automatically updatable. -Where, for updates, we actually have to check for 'edit' instead of 'view' operation, which makes it a bit more complicated. +Where, for updates, we actually have to check for 'UPDATE' instead of 'SELECT' operation, which makes it a bit more complicated. With the larger dataset, the test suite initially needed over 7 seconds with this view query. At this point the second variant was tried. @@ -642,7 +642,7 @@ Looks like the query optimizer needed some statistics to find the best path. SELECT DISTINCT target.* FROM customer AS target JOIN queryAccessibleObjectUuidsOfSubjectIds( - 'view', 'customer', currentSubjectsUuids()) AS allowedObjId + 'SELECT, 'customer', currentSubjectsUuids()) AS allowedObjId ON target.uuid = allowedObjId; This view cannot is not updatable automatically, @@ -688,13 +688,13 @@ Otherwise, it would not be possible to assign roles to new users. All roles are system-defined and cannot be created or modified by any external API. -Users can view only the roles to which they are assigned. +Users can view only the roles to which are granted to them. ## RbacGrant Grant can be `empowered`, this means that the grantee user can grant the granted role to other users and revoke grants to that role. -(TODO: access control part not yet implemented) +(TODO: access control part not yet implemented, currently all accessible roles can be granted to other users) Grants can be `managed`, which means they are created and deleted by system-defined rules. If a grant is not managed, it was created by an empowered user and can be deleted by empowered users. diff --git a/doc/test-concept.md b/doc/test-concept.md index c8946342..690d1558 100644 --- a/doc/test-concept.md +++ b/doc/test-concept.md @@ -87,7 +87,7 @@ Acceptance-Tests run on a fully integrated and deployed system with deployed dou Acceptance-tests, are blackbox-tests and do not count into test-code-coverage. -TODO: Complete the Acceptance-Tests test concept. +TODO.test: Complete the Acceptance-Tests test concept. #### Performance-Tests @@ -107,4 +107,4 @@ We define System-Integration-Tests as test in which this system is deployed in a System-Integration-tests, are blackbox-tests and do not count into test-code-coverage. -TODO: Complete the System-Integration-Tests test concept. +TODO.test: Complete the System-Integration-Tests test concept. diff --git a/etc/docker-compose.yml b/etc/docker-compose.yml new file mode 100644 index 00000000..f35c4077 --- /dev/null +++ b/etc/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + postgres: + image: postgres-with-contrib:15.5-bookworm + container_name: custom-postgres + environment: + POSTGRES_PASSWORD: password + volumes: + - ./postgresql-log-slow-queries.conf:/etc/postgresql/postgresql.conf + ports: + - "5432:5432" + command: + - bash + - -c + - > + apt-get update && + apt-get install -y postgresql-contrib && + docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf + deploy: + resources: + limits: + cpus: '2' + memory: 8G + reservations: + cpus: '1' + memory: 2G diff --git a/etc/owasp-dependency-check-suppression.xml b/etc/owasp-dependency-check-suppression.xml index 39d77b47..af4269d4 100644 --- a/etc/owasp-dependency-check-suppression.xml +++ b/etc/owasp-dependency-check-suppression.xml @@ -1,33 +1,5 @@ - - - ^pkg:maven/org\.springframework/spring-web@.*$ - CVE-2016-1000027 - - - - ^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$ - CVE-2022-42003 - - - - ^pkg:maven/org\.eclipse\.angus/angus\-activation@.*$ - cpe:/a:eclipse:eclipse_ide - - - - ^pkg:maven/jakarta\.activation/jakarta\.activation\-api@.*$ - cpe:/a:eclipse:eclipse_ide - ^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$ cpe:/a:fasterxml:jackson-databind - - - ^pkg:maven/com\.jayway\.jsonpath/json\-path@.*$ - CVE-2023-51074 - ^pkg:maven/org\.pitest/pitest\-command\-line@.*$ cpe:/a:line:line - - - ^pkg:maven/org\.yaml/snakeyaml@.*$ - CVE-2022-1471 - diff --git a/etc/postgresql-log-slow-queries.conf b/etc/postgresql-log-slow-queries.conf new file mode 100644 index 00000000..a466c127 --- /dev/null +++ b/etc/postgresql-log-slow-queries.conf @@ -0,0 +1,10 @@ +shared_preload_libraries = 'pg_stat_statements,auto_explain' +log_min_duration_statement = 1000 +log_statement = 'all' +log_duration = on +pg_stat_statements.track = all +auto_explain.log_min_duration = '1s' # Logs queries taking longer than 1 second +auto_explain.log_analyze = on # Include actual run times +auto_explain.log_buffers = on # Include buffer usage statistics +auto_explain.log_format = 'json' # Format the log output in JSON +listen_addresses = '*' diff --git a/settings.gradle b/settings.gradle index 09d09d6f..d6f3f9eb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,28 +11,4 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' } -dependencyResolutionManagement { - components { - all { - allVariants { - withDependencies { - removeAll { - // Spring Boot 3.1.x has a transient dependency to snakeyaml 1.3 - // which contains a severe vulnerability. - // Here we remove this transient dependency and in build.gradle - // we add an explicit dependency to snakeyaml 2.2, - // which does not have this vulnerability anymore. - // - // TODO: Check Once we are on SpringBoot 3.2.x, check if this exclude - // is still neccessary. If not: - // Remove it // as well as the related explicit dependency in build.gradle - // and the dependency suppression in owasp-dependency-check-suppression.xml. - it.module in [ 'snakeyaml' ] - } - } - } - } - } -} - rootProject.name = 'hsadmin-ng' diff --git a/sql/examples.sql b/sql/examples.sql deleted file mode 100644 index 13219654..00000000 --- a/sql/examples.sql +++ /dev/null @@ -1,53 +0,0 @@ --- ======================================================== --- First Example Entity with History --- -------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS customer ( - "id" SERIAL PRIMARY KEY, - "reference" int not null unique, -- 10000-99999 - "prefix" character(3) unique - ); - -CALL create_historicization('customer'); - - --- ======================================================== --- Second Example Entity with History --- -------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS package_type ( - "id" serial PRIMARY KEY, - "name" character varying(8) - ); - -CALL create_historicization('package_type'); - --- ======================================================== --- Third Example Entity with History --- -------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS package ( - "id" serial PRIMARY KEY, - "name" character varying(5), - "customer_id" INTEGER REFERENCES customer(id) - ); - -CALL create_historicization('package'); - - --- ======================================================== --- query historical data --- -------------------------------------------------------- - - -ABORT; -BEGIN TRANSACTION; -SET LOCAL hsadminng.currentUser TO 'mih42_customer_aaa'; -SET LOCAL hsadminng.currentTask TO 'adding customer_aaa'; -INSERT INTO package (customer_id, name) VALUES (10000, 'aaa00'); -COMMIT; --- Usage: - -SET hsadminng.timestamp TO '2022-07-12 08:53:27.723315'; -SET hsadminng.timestamp TO '2022-07-12 11:38:27.723315'; -SELECT * FROM customer_hv p WHERE prefix = 'aaa'; diff --git a/sql/historization.sql b/sql/historization.sql index 2f4087b4..6f50f428 100644 --- a/sql/historization.sql +++ b/sql/historization.sql @@ -1,166 +1,39 @@ -- ======================================================== --- Historization +-- Historization twiddle -- -------------------------------------------------------- -CREATE TABLE "tx_history" ( - "tx_id" BIGINT NOT NULL UNIQUE, - "tx_timestamp" TIMESTAMP NOT NULL, - "user" VARCHAR(64) NOT NULL, -- references postgres user - "task" VARCHAR NOT NULL -); +rollback; +begin transaction; +call defineContext('historization testing', null, 'superuser-alex@hostsharing.net', +-- 'hs_booking_project#D-1000000-hshdefaultproject:ADMIN'); -- prod+test + 'hs_booking_project#D-1000313-D-1000313defaultproject:ADMIN'); -- prod+test +-- 'hs_booking_project#D-1000300-mihdefaultproject:ADMIN'); -- prod +-- 'hs_booking_project#D-1000300-mimdefaultproject:ADMIN'); -- test +-- update hs_hosting_asset set caption='lug00 b' where identifier = 'lug00' and type = 'MANAGED_WEBSPACE'; -- prod +-- update hs_hosting_asset set caption='hsh00 A ' || now()::text where identifier = 'hsh00' and type = 'MANAGED_WEBSPACE'; -- test +-- update hs_hosting_asset set caption='hsh00 B ' || now()::text where identifier = 'hsh00' and type = 'MANAGED_WEBSPACE'; -- test -CREATE TYPE "operation" AS ENUM ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'); +-- insert into hs_hosting_asset +-- (uuid, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, identifier, caption, config, alarmcontactuuid) +-- values +-- (uuid_generate_v4(), null, 'EMAIL_ADDRESS', 'bbda5895-0569-4e20-bb4c-34f3a38f3f63'::uuid, null, +-- 'new@thi.example.org', 'some new E-Mail-Address', '{}'::jsonb, null); --- see https://www.postgresql.org/docs/current/plpgsql-trigger.html +delete from hs_hosting_asset where uuid='5aea68d2-3b55-464f-8362-b05c76c5a681'::uuid; +commit; -CREATE OR REPLACE FUNCTION historicize() - RETURNS trigger - LANGUAGE plpgsql STRICT AS $$ -DECLARE -currentUser VARCHAR(64); - currentTask varchar; - "row" RECORD; - "alive" BOOLEAN; - "sql" varchar; -BEGIN - -- determine user_id -BEGIN - currentUser := current_setting('hsadminng.currentUser'); -EXCEPTION WHEN OTHERS THEN - currentUser := NULL; -END; - IF (currentUser IS NULL OR currentUser = '') THEN - RAISE EXCEPTION 'hsadminng.currentUser must be defined, please use "SET LOCAL ...;"'; -END IF; - RAISE NOTICE 'currentUser: %', currentUser; +-- single version at point in time +-- set hsadminng.tx_history_txid to (select max(txid) from tx_context where txtimestamp<='2024-08-27 12:13:13.450821'); +set hsadminng.tx_history_txid to ''; +set hsadminng.tx_history_timestamp to '2024-08-29 12:42'; +-- all versions +select tx_history_txid(), txc.txtimestamp, txc.currentUser, txc.currentTask, haex.* + from hs_hosting_asset_ex haex + join tx_context txc on haex.txid=txc.txid + where haex.identifier = 'test@thi.example.org'; - -- determine task - currentTask = current_setting('hsadminng.currentTask'); - IF (currentTask IS NULL OR length(currentTask) < 12) THEN - RAISE EXCEPTION 'hsadminng.currentTask (%) must be defined and min 12 characters long, please use "SET LOCAL ...;"', currentTask; -END IF; - RAISE NOTICE 'currentTask: %', currentTask; +select uuid, version, type, identifier, caption from hs_hosting_asset_hv p where identifier = 'test@thi.example.org'; - IF (TG_OP = 'INSERT') OR (TG_OP = 'UPDATE') THEN - "row" := NEW; - "alive" := TRUE; -ELSE -- DELETE or TRUNCATE - "row" := OLD; - "alive" := FALSE; -END IF; +select pg_current_xact_id(); -sql := format('INSERT INTO tx_history VALUES (txid_current(), now(), %1L, %2L) ON CONFLICT DO NOTHING', currentUser, currentTask); - RAISE NOTICE 'sql: %', sql; -EXECUTE sql; -sql := format('INSERT INTO %3$I_versions VALUES (DEFAULT, txid_current(), %1$L, %2$L, $1.*)', TG_OP, alive, TG_TABLE_NAME); - RAISE NOTICE 'sql: %', sql; -EXECUTE sql USING "row"; - -RETURN "row"; -END; $$; - -CREATE OR REPLACE PROCEDURE create_historical_view(baseTable varchar) - LANGUAGE plpgsql AS $$ -DECLARE -createTriggerSQL varchar; - viewName varchar; - versionsTable varchar; - createViewSQL varchar; - baseCols varchar; -BEGIN - - viewName = quote_ident(format('%s_hv', baseTable)); - versionsTable = quote_ident(format('%s_versions', baseTable)); - baseCols = (SELECT string_agg(quote_ident(column_name), ', ') - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = baseTable); - - createViewSQL = format( - 'CREATE OR REPLACE VIEW %1$s AS' || - '(' || - ' SELECT %2$s' || - ' FROM %3$s' || - ' WHERE alive = TRUE' || - ' AND version_id IN' || - ' (' || - ' SELECT max(vt.version_id) AS history_id' || - ' FROM %3$s AS vt' || - ' JOIN tx_history as txh ON vt.tx_id = txh.tx_id' || - ' WHERE txh.tx_timestamp <= current_setting(''hsadminng.timestamp'')::timestamp' || - ' GROUP BY id' || - ' )' || - ')', - viewName, baseCols, versionsTable - ); - RAISE NOTICE 'sql: %', createViewSQL; -EXECUTE createViewSQL; - -createTriggerSQL = 'CREATE TRIGGER ' || baseTable || '_historicize' || - ' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable || - ' FOR EACH ROW EXECUTE PROCEDURE historicize()'; - RAISE NOTICE 'sql: %', createTriggerSQL; -EXECUTE createTriggerSQL; - -END; $$; - -CREATE OR REPLACE PROCEDURE create_historicization(baseTable varchar) - LANGUAGE plpgsql AS $$ -DECLARE - createHistTableSql varchar; - createTriggerSQL varchar; - viewName varchar; - versionsTable varchar; - createViewSQL varchar; - baseCols varchar; -BEGIN - - -- create the history table - createHistTableSql = '' || - 'CREATE TABLE ' || baseTable || '_versions (' || - ' version_id serial PRIMARY KEY,' || - ' tx_id bigint NOT NULL REFERENCES tx_history(tx_id),' || - ' trigger_op operation NOT NULL,' || - ' alive boolean not null,' || - - ' LIKE ' || baseTable || - ' EXCLUDING CONSTRAINTS' || - ' EXCLUDING STATISTICS' || - ')'; - RAISE NOTICE 'sql: %', createHistTableSql; - EXECUTE createHistTableSql; - - -- create the historical view - viewName = quote_ident(format('%s_hv', baseTable)); - versionsTable = quote_ident(format('%s_versions', baseTable)); - baseCols = (SELECT string_agg(quote_ident(column_name), ', ') - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = baseTable); - - createViewSQL = format( - 'CREATE OR REPLACE VIEW %1$s AS' || - '(' || - ' SELECT %2$s' || - ' FROM %3$s' || - ' WHERE alive = TRUE' || - ' AND version_id IN' || - ' (' || - ' SELECT max(vt.version_id) AS history_id' || - ' FROM %3$s AS vt' || - ' JOIN tx_history as txh ON vt.tx_id = txh.tx_id' || - ' WHERE txh.tx_timestamp <= current_setting(''hsadminng.timestamp'')::timestamp' || - ' GROUP BY id' || - ' )' || - ')', - viewName, baseCols, versionsTable - ); - RAISE NOTICE 'sql: %', createViewSQL; - EXECUTE createViewSQL; - - createTriggerSQL = 'CREATE TRIGGER ' || baseTable || '_historicize' || - ' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable || - ' FOR EACH ROW EXECUTE PROCEDURE historicize()'; - RAISE NOTICE 'sql: %', createTriggerSQL; - EXECUTE createTriggerSQL; - -END; $$; diff --git a/sql/rbac-tests.sql b/sql/rbac-tests.sql index 0183a6a2..351d1509 100644 --- a/sql/rbac-tests.sql +++ b/sql/rbac-tests.sql @@ -3,10 +3,10 @@ -- -------------------------------------------------------- -select isGranted(findRoleId('administrators'), findRoleId('test_package#aaa00.owner')); -select isGranted(findRoleId('test_package#aaa00.owner'), findRoleId('administrators')); --- call grantRoleToRole(findRoleId('test_package#aaa00.owner'), findRoleId('administrators')); --- call grantRoleToRole(findRoleId('administrators'), findRoleId('test_package#aaa00.owner')); +select isGranted(findRoleId('administrators'), findRoleId('test_package#aaa00:OWNER')); +select isGranted(findRoleId('test_package#aaa00:OWNER'), findRoleId('administrators')); +-- call grantRoleToRole(findRoleId('test_package#aaa00:OWNER'), findRoleId('administrators')); +-- call grantRoleToRole(findRoleId('administrators'), findRoleId('test_package#aaa00:OWNER')); select count(*) FROM queryAllPermissionsOfSubjectIdForObjectUuids(findRbacUser('superuser-fran@hostsharing.net'), @@ -19,13 +19,13 @@ select * FROM queryAllPermissionsOfSubjectId(findRbacUser('rosa@example.com')); select * -FROM queryAllRbacUsersWithPermissionsFor(findPermissionId('customer', +FROM queryAllRbacUsersWithPermissionsFor(findEffectivePermissionId('customer', (SELECT uuid FROM RbacObject WHERE objectTable = 'customer' LIMIT 1), 'add-package')); select * -FROM queryAllRbacUsersWithPermissionsFor(findPermissionId('package', +FROM queryAllRbacUsersWithPermissionsFor(findEffectivePermissionId('package', (SELECT uuid FROM RbacObject WHERE objectTable = 'package' LIMIT 1), - 'delete')); + 'DELETE')); DO LANGUAGE plpgsql $$ @@ -39,7 +39,7 @@ $$ RAISE EXCEPTION 'expected permission NOT to be granted, but it is'; end if; - result = (SELECT * FROM isPermissionGrantedToSubject(findPermissionId('package', 94928, 'view'), userId)); + result = (SELECT * FROM isPermissionGrantedToSubject(findPermissionId('package', 94928, 'SELECT'), userId)); IF (NOT result) THEN RAISE EXCEPTION 'expected permission to be granted, but it is NOT'; end if; diff --git a/sql/rbac-view-option-experiments.sql b/sql/rbac-view-option-experiments.sql index 3a6cab1a..c5c04487 100644 --- a/sql/rbac-view-option-experiments.sql +++ b/sql/rbac-view-option-experiments.sql @@ -20,7 +20,7 @@ CREATE POLICY customer_policy ON customer TO restricted USING ( -- id=1000 - isPermissionGrantedToSubject(findPermissionId('test_customer', id, 'view'), currentUserUuid()) + isPermissionGrantedToSubject(findEffectivePermissionId('test_customer', id, 'SELECT'), currentUserUuid()) ); SET SESSION AUTHORIZATION restricted; @@ -35,7 +35,7 @@ SELECT * FROM customer; CREATE OR REPLACE RULE "_RETURN" AS ON SELECT TO cust_view DO INSTEAD - SELECT * FROM customer WHERE isPermissionGrantedToSubject(findPermissionId('test_customer', id, 'view'), currentUserUuid()); + SELECT * FROM customer WHERE isPermissionGrantedToSubject(findEffectivePermissionId('test_customer', id, 'SELECT'), currentUserUuid()); SELECT * from cust_view LIMIT 10; select queryAllPermissionsOfSubjectId(findRbacUser('superuser-alex@hostsharing.net')); @@ -52,7 +52,7 @@ CREATE OR REPLACE RULE "_RETURN" AS DO INSTEAD SELECT c.uuid, c.reference, c.prefix FROM customer AS c JOIN queryAllPermissionsOfSubjectId(currentUserUuid()) AS p - ON p.objectTable='test_customer' AND p.objectUuid=c.uuid AND p.op in ('*', 'view'); + ON p.objectTable='test_customer' AND p.objectUuid=c.uuid; GRANT ALL PRIVILEGES ON cust_view TO restricted; SET SESSION SESSION AUTHORIZATION restricted; @@ -68,7 +68,7 @@ CREATE OR REPLACE VIEW cust_view AS SELECT c.uuid, c.reference, c.prefix FROM customer AS c JOIN queryAllPermissionsOfSubjectId(currentUserUuid()) AS p - ON p.objectUuid=c.uuid AND p.op in ('*', 'view'); + ON p.objectUuid=c.uuid; GRANT ALL PRIVILEGES ON cust_view TO restricted; SET SESSION SESSION AUTHORIZATION restricted; @@ -81,9 +81,9 @@ select rr.uuid, rr.type from RbacGrants g join RbacReference RR on g.ascendantUuid = RR.uuid where g.descendantUuid in ( select uuid from queryAllPermissionsOfSubjectId(findRbacUser('alex@example.com')) - where objectTable='test_customer' and op in ('*', 'view')); + where objectTable='test_customer'); -call grantRoleToUser(findRoleId('test_customer#aaa.admin'), findRbacUser('aaaaouq@example.com')); +call grantRoleToUser(findRoleId('test_customer#aaa:ADMIN'), findRbacUser('aaaaouq@example.com')); select queryAllPermissionsOfSubjectId(findRbacUser('aaaaouq@example.com')); diff --git a/sql/recursive-cte-experiments-for-accessible-uuids.sql b/sql/recursive-cte-experiments-for-accessible-uuids.sql new file mode 100644 index 00000000..5e9a7be5 --- /dev/null +++ b/sql/recursive-cte-experiments-for-accessible-uuids.sql @@ -0,0 +1,175 @@ +-- just a permanent playground to explore optimization of the central recursive CTE query for RBAC + +select * from hs_statistics_view; + +-- ======================================================== + +-- This is the extracted recursive CTE query to determine the visible object UUIDs of a single table +-- (and optionally the hosting-asset-type) as a separate VIEW. +-- In the generated code this is part of the hs_hosting_asset_rv VIEW. + +drop view if exists hs_hosting_asset_example_gv; +create view hs_hosting_asset_example_gv as +with recursive + recursive_grants as ( + select distinct rbacgrants.descendantuuid, + rbacgrants.ascendantuuid, + 1 as level, + true + from rbacgrants + where (rbacgrants.ascendantuuid = any (currentsubjectsuuids())) + and rbacgrants.assumed + union all + select distinct g.descendantuuid, + g.ascendantuuid, + grants.level + 1 as level, + assertTrue(grants.level < 22, 'too many grant-levels: ' || grants.level) + from rbacgrants g + join recursive_grants grants on grants.descendantuuid = g.ascendantuuid + where g.assumed + ), + grant_count as ( + select count(*) as grant_count from recursive_grants + ), + count_check as ( + select assertTrue((select grant_count from grant_count) < 600000, + 'too many grants for current subjects: ' || (select grant_count from grant_count)) as valid + ) +select distinct perm.objectuuid + from recursive_grants + join rbacpermission perm on recursive_grants.descendantuuid = perm.uuid + join rbacobject obj on obj.uuid = perm.objectuuid + join count_check cc on cc.valid + where obj.objecttable::text = 'hs_hosting_asset'::text + -- with/without this type condition +-- and obj.type = 'EMAIL_ADDRESS'::hshostingassettype + and obj.type = 'EMAIL_ADDRESS'::hshostingassettype +; + +-- ----------------------------------------------------------------------------------------------- + +-- A query just on the above view, only determining visible objects, no JOIN with business data: + +rollback transaction; +begin transaction; +CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net', + 'hs_booking_project#D-1000000-hshdefaultproject:ADMIN'); +-- 'hs_booking_project#D-1000300-mihdefaultproject:ADMIN'); +SET TRANSACTION READ ONLY; +EXPLAIN ANALYZE select * from hs_hosting_asset_example_gv; +end transaction ; + +-- ======================================================== + +-- An example for a restricted view (_rv) similar to the one generated by our RBAC system, +-- but using the above separate VIEW to determine the visible objects. + +drop view if exists hs_hosting_asset_example_rv; +create view hs_hosting_asset_example_rv as + with accessible_hs_hosting_asset_uuids as ( + select * from hs_hosting_asset_example_gv + ) + select target.* + from hs_hosting_asset target + where (target.uuid in (select accessible_hs_hosting_asset_uuids.objectuuid + from accessible_hs_hosting_asset_uuids)); + +-- ------------------------------------------------------------------------------- + +-- performing several queries on the above view to determine average performance: + +rollback transaction; +DO language plpgsql $$ +DECLARE + start_time timestamp; + end_time timestamp; + total_time interval; + letter char(1); +BEGIN + start_time := clock_timestamp(); + + CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net', + 'hs_booking_project#D-1000000-hshdefaultproject:ADMIN'); +-- 'hs_booking_project#D-1000300-mihdefaultproject:ADMIN'); + SET TRANSACTION READ ONLY; + + FOR i IN 0..25 LOOP + letter := chr(i+ascii('a')); + PERFORM count(*) from ( + + -- An example for a business query based on the view: + select type, uuid, identifier, caption + from hs_hosting_asset_example_rv + where type = 'EMAIL_ADDRESS' + and identifier like letter || '%' + -- end of the business query example. + + ) AS timed; + + END LOOP; + + end_time := clock_timestamp(); + total_time := end_time - start_time; + + RAISE NOTICE 'average execution time: %', total_time/26; +END; +$$; + +-- average seconds per recursive CTE select as role 'hs_hosting_asset:defaultproject:ADMIN' +-- joined with business query for all 'EMAIL_ADDRESSES': +-- D-1000000-hsh D-1000300-mih +-- - without type comparison in rbacobject: ~3.30 - ~3.49 ~0.23 +-- - with type comparison in rbacobject: ~2.99 - ~3.08 ~0.21 + +-- ------------------------------------------------------------------------------- + +-- and a single query, so EXPLAIN can be used + +rollback transaction; +begin transaction; +CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net', + 'hs_booking_project#D-1000000-hshdefaultproject:ADMIN'); +-- 'hs_booking_project#D-1000300-mihdefaultproject:ADMIN'); +SET TRANSACTION READ ONLY; + +EXPLAIN SELECT * from ( + + -- An example for a business query based on the view: + select type, uuid, identifier, caption + from hs_hosting_asset_example_rv + where type = 'EMAIL_ADDRESS' +-- and identifier like 'b%' + -- end of the business query example. + + ) ha; + +end transaction; + +-- ============================================================================= + +-- extending the rbacobject table: + +alter table rbacobject + -- just for performance testing, we would need a joined enum or a varchar(16) which would make it slow + add column type hshostingassettype; + +-- and fill the type column with hs_hosting_asset types: + +rollback transaction; +begin transaction; +call defineContext('setting rbacobject.type from hs_hosting_asset.type', null, 'superuser-alex@hostsharing.net'); + + UPDATE rbacobject + SET type = hs.type + FROM hs_hosting_asset hs + WHERE rbacobject.uuid = hs.uuid; + +end transaction; + +-- check the result: + +select + (select count(*) as "total" from rbacobject), + (select count(*) as "not null" from rbacobject where type is not null), + (select count(*) as "null" from rbacobject where type is null); + diff --git a/src/main/java/net/hostsharing/hsadminng/context/Context.java b/src/main/java/net/hostsharing/hsadminng/context/Context.java index 2730147d..b3dac96b 100644 --- a/src/main/java/net/hostsharing/hsadminng/context/Context.java +++ b/src/main/java/net/hostsharing/hsadminng/context/Context.java @@ -15,11 +15,9 @@ import java.util.Collections; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Function; import java.util.stream.Collectors; import static java.util.function.Predicate.not; -import static net.hostsharing.hsadminng.mapper.PostgresArray.fromPostgresArray; import static org.springframework.transaction.annotation.Propagation.MANDATORY; @Service @@ -55,16 +53,15 @@ public class Context { final String currentRequest, final String currentUser, final String assumedRoles) { - final var query = em.createNativeQuery( - """ - call defineContext( - cast(:currentTask as varchar), - cast(:currentRequest as varchar), - cast(:currentUser as varchar), - cast(:assumedRoles as varchar)); - """); - query.setParameter("currentTask", shortenToMaxLength(currentTask, 96)); - query.setParameter("currentRequest", shortenToMaxLength(currentRequest, 512)); // TODO.spec: length? + final var query = em.createNativeQuery(""" + call defineContext( + cast(:currentTask as varchar(127)), + cast(:currentRequest as text), + cast(:currentUser as varchar(63)), + cast(:assumedRoles as varchar(1023))); + """); + query.setParameter("currentTask", shortenToMaxLength(currentTask, 127)); + query.setParameter("currentRequest", currentRequest); query.setParameter("currentUser", currentUser); query.setParameter("assumedRoles", assumedRoles != null ? assumedRoles : ""); query.executeUpdate(); @@ -83,14 +80,11 @@ public class Context { } public String[] getAssumedRoles() { - final byte[] result = (byte[]) em.createNativeQuery("select assumedRoles() as roles", String[].class).getSingleResult(); - return fromPostgresArray(result, String.class, Function.identity()); + return (String[]) em.createNativeQuery("select assumedRoles() as roles", String[].class).getSingleResult(); } public UUID[] currentSubjectsUuids() { - final byte[] result = (byte[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class) - .getSingleResult(); - return fromPostgresArray(result, UUID.class, UUID::fromString); + return (UUID[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class).getSingleResult(); } public static String getCallerMethodNameFromStackFrame(final int skipFrames) { diff --git a/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java b/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java index 2714b817..3df51ebb 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java @@ -9,7 +9,7 @@ import org.springframework.web.context.request.WebRequest; import java.time.LocalDateTime; @Getter -class CustomErrorResponse { +public class CustomErrorResponse { static ResponseEntity errorResponse( final WebRequest request, @@ -46,6 +46,6 @@ class CustomErrorResponse { this.path = path; this.statusCode = status.value(); this.statusPhrase = status.getReasonPhrase(); - this.message = message; + this.message = message.startsWith("ERROR: [") ? message : "ERROR: [" + statusCode + "] " + message; } } diff --git a/src/main/java/net/hostsharing/hsadminng/errors/DisplayAs.java b/src/main/java/net/hostsharing/hsadminng/errors/DisplayAs.java new file mode 100644 index 00000000..020d006a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/DisplayAs.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.errors; + +import jakarta.validation.constraints.NotNull; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface DisplayAs { + class DisplayName { + public static String of(final Class clazz) { + final var displayNameAnnot = clazz.getAnnotation(DisplayAs.class); + return displayNameAnnot != null ? displayNameAnnot.value() : clazz.getSimpleName(); + } + + public static String of(@NotNull final Object instance) { + return of(instance.getClass()); + } + } + + String value() default ""; +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java b/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java deleted file mode 100644 index 8c5eed4c..00000000 --- a/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.hostsharing.hsadminng.errors; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface DisplayName { - String value() default ""; -} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java b/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java new file mode 100644 index 00000000..c8e721a2 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.errors; + +import jakarta.validation.ValidationException; +import java.util.List; + +import static java.lang.String.join; + +public class MultiValidationException extends ValidationException { + + private MultiValidationException(final List violations) { + super( + violations.size() > 1 + ? "[\n" + join(",\n", violations) + "\n]" + : "[" + join(",\n", violations) + "]" + ); + } + + public static void throwIfNotEmpty(final List violations) { + if (!violations.isEmpty()) { + throw new MultiValidationException(violations); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/ReferenceNotFoundException.java b/src/main/java/net/hostsharing/hsadminng/errors/ReferenceNotFoundException.java new file mode 100644 index 00000000..7d032d50 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/ReferenceNotFoundException.java @@ -0,0 +1,19 @@ +package net.hostsharing.hsadminng.errors; + +import java.util.UUID; + +public class ReferenceNotFoundException extends RuntimeException { + + private final Class entityClass; + private final UUID uuid; + public ReferenceNotFoundException(final Class entityClass, final UUID uuid, final Throwable exc) { + super(exc); + this.entityClass = entityClass; + this.uuid = uuid; + } + + @Override + public String getMessage() { + return "Cannot resolve " + entityClass.getSimpleName() +" with uuid " + uuid; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index 536cbf16..c366d7bc 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -11,16 +11,18 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.lang.Nullable; import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.validation.FieldError; +import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import jakarta.persistence.EntityNotFoundException; import jakarta.validation.ValidationException; -import java.util.NoSuchElementException; -import java.util.Optional; +import java.util.*; import java.util.regex.Pattern; import static net.hostsharing.hsadminng.errors.CustomErrorResponse.*; @@ -45,7 +47,7 @@ public class RestResponseEntityExceptionHandler protected ResponseEntity handleJpaExceptions( final RuntimeException exc, final WebRequest request) { final var message = line(NestedExceptionUtils.getMostSpecificCause(exc).getMessage(), 0); - return errorResponse(request, httpStatus(message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message); + return errorResponse(request, httpStatus(exc, message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message); } @ExceptionHandler(NoSuchElementException.class) @@ -55,6 +57,12 @@ public class RestResponseEntityExceptionHandler return errorResponse(request, HttpStatus.NOT_FOUND, message); } + @ExceptionHandler(ReferenceNotFoundException.class) + protected ResponseEntity handleReferenceNotFoundException( + final ReferenceNotFoundException exc, final WebRequest request) { + return errorResponse(request, HttpStatus.BAD_REQUEST, exc.getMessage()); + } + @ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class }) protected ResponseEntity handleJpaObjectRetrievalFailureException( final RuntimeException exc, final WebRequest request) { @@ -65,17 +73,19 @@ public class RestResponseEntityExceptionHandler } @ExceptionHandler({ Iban4jException.class, ValidationException.class }) - protected ResponseEntity handleIbanAndBicExceptions( + protected ResponseEntity handleValidationExceptions( final Throwable exc, final WebRequest request) { - final var message = line(NestedExceptionUtils.getMostSpecificCause(exc).getMessage(), 0); + final String fullMessage = NestedExceptionUtils.getMostSpecificCause(exc).getMessage(); + final var message = exc instanceof MultiValidationException ? fullMessage : line(fullMessage, 0); return errorResponse(request, HttpStatus.BAD_REQUEST, message); } @ExceptionHandler(Throwable.class) protected ResponseEntity handleOtherExceptions( final Throwable exc, final WebRequest request) { - final var message = firstMessageLine(NestedExceptionUtils.getMostSpecificCause(exc)); - return errorResponse(request, httpStatus(message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message); + final var causingException = NestedExceptionUtils.getMostSpecificCause(exc); + final var message = firstMessageLine(causingException); + return errorResponse(request, httpStatus(causingException, message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message); } @Override @@ -112,6 +122,28 @@ public class RestResponseEntityExceptionHandler return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString()); } + @SuppressWarnings("unchecked,rawtypes") + + @Override + protected ResponseEntity handleHandlerMethodValidationException( + final HandlerMethodValidationException exc, + final HttpHeaders headers, + final HttpStatusCode status, + final WebRequest request) { + final var errorList = exc + .getAllValidationResults() + .stream() + .map(ParameterValidationResult::getResolvableErrors) + .flatMap(Collection::stream) + .filter(FieldError.class::isInstance) + .map(FieldError.class::cast) + .map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() + " but is \"" + + fieldError.getRejectedValue() + "\"") + .toList(); + return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString()); + } + + private String userReadableEntityClassName(final String exceptionMessage) { final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) "; final var pattern = Pattern.compile(regex); @@ -120,8 +152,8 @@ public class RestResponseEntityExceptionHandler final var entityName = matcher.group(1); final var entityClass = resolveClass(entityName); if (entityClass.isPresent()) { - return (entityClass.get().isAnnotationPresent(DisplayName.class) - ? exceptionMessage.replace(entityName, entityClass.get().getAnnotation(DisplayName.class).value()) + return (entityClass.get().isAnnotationPresent(DisplayAs.class) + ? exceptionMessage.replace(entityName, entityClass.get().getAnnotation(DisplayAs.class).value()) : exceptionMessage.replace(entityName, entityClass.get().getSimpleName())) .replace(" with id ", " with uuid "); } @@ -138,7 +170,10 @@ public class RestResponseEntityExceptionHandler } } - private Optional httpStatus(final String message) { + private Optional httpStatus(final Throwable causingException, final String message) { + if ( EntityNotFoundException.class.isInstance(causingException) ) { + return Optional.of(HttpStatus.BAD_REQUEST); + } if (message.startsWith("ERROR: [")) { for (HttpStatus status : HttpStatus.values()) { if (message.startsWith("ERROR: [" + status.value() + "]")) { diff --git a/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java b/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java new file mode 100644 index 00000000..cd16b697 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java @@ -0,0 +1,130 @@ +package net.hostsharing.hsadminng.hash; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.function.BiFunction; +import java.util.random.RandomGenerator; + +import lombok.Getter; + +/** + * Usage-example to generate hash: + * HashGenerator.using(LINUX_SHA512).withRandomSalt().hash("plaintext password"); + * + * Usage-example to verify hash: + * HashGenerator.fromHash("hashed password).verify("plaintext password"); + */ +@Getter +public final class HashGenerator { + + private static final RandomGenerator random = new SecureRandom(); + private static final Queue predefinedSalts = new PriorityQueue<>(); + + public static final int RANDOM_SALT_LENGTH = 16; + private static final String RANDOM_SALT_CHARACTERS = + "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789/."; + private static boolean couldBeHashEnabled; // TODO.impl: remove after legacy data is migrated + + public enum Algorithm { + LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"), + LINUX_YESCRYPT(LinuxEtcShadowHashGenerator::hash, "y", "j9T$") { + @Override + String enrichedSalt(final String salt) { + return prefix + "$" + (salt.startsWith(optionalParam) ? salt : optionalParam + salt); + } + }, + MYSQL_NATIVE(MySQLNativePasswordHashGenerator::hash, "*"), + SCRAM_SHA256(PostgreSQLScramSHA256::hash, "SCRAM-SHA-256"); + + final BiFunction implementation; + final String prefix; + final String optionalParam; + + Algorithm(BiFunction implementation, final String prefix, final String optionalParam) { + this.implementation = implementation; + this.prefix = prefix; + this.optionalParam = optionalParam; + } + + Algorithm(BiFunction implementation, final String prefix) { + this(implementation, prefix, null); + } + + static Algorithm byPrefix(final String prefix) { + return Arrays.stream(Algorithm.values()).filter(a -> a.prefix.equals(prefix)).findAny() + .orElseThrow(() -> new IllegalArgumentException("unknown hash algorithm: '" + prefix + "'")); + } + + String enrichedSalt(final String salt) { + return prefix + "$" + salt; + } + } + + private final Algorithm algorithm; + private String salt; + + public static HashGenerator using(final Algorithm algorithm) { + return new HashGenerator(algorithm); + } + + private HashGenerator(final Algorithm algorithm) { + this.algorithm = algorithm; + } + + public static void enableCouldBeHash(final boolean enable) { + couldBeHashEnabled = enable; + } + + public boolean couldBeHash(final String value) { + return couldBeHashEnabled && value.startsWith(algorithm.prefix); + } + + public String hash(final String plaintextPassword) { + if (plaintextPassword == null) { + throw new IllegalStateException("no password given"); + } + + final var hash = algorithm.implementation.apply(this, plaintextPassword); + if (hash.length() < plaintextPassword.length()) { + throw new AssertionError("generated hash too short: " + hash); + } + return hash; + } + + public String hashIfNotYetHashed(final String plaintextPasswordOrHash) { + return couldBeHash(plaintextPasswordOrHash) + ? plaintextPasswordOrHash + : hash(plaintextPasswordOrHash); + } + + public static void nextSalt(final String salt) { + predefinedSalts.add(salt); + } + + public HashGenerator withSalt(final String salt) { + this.salt = salt; + return this; + } + + public HashGenerator withRandomSalt() { + if (!predefinedSalts.isEmpty()) { + return withSalt(predefinedSalts.poll()); + } + final var stringBuilder = new StringBuilder(RANDOM_SALT_LENGTH); + for (int i = 0; i < RANDOM_SALT_LENGTH; ++i) { + int randomIndex = random.nextInt(RANDOM_SALT_CHARACTERS.length()); + stringBuilder.append(RANDOM_SALT_CHARACTERS.charAt(randomIndex)); + } + return withSalt(stringBuilder.toString()); + } + + public static void main(String[] args) { + System.out.println( + HashGenerator.using(Algorithm.LINUX_YESCRYPT).withRandomSalt().hash("my plaintext domain transfer passphrase") + ); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java b/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java new file mode 100644 index 00000000..b5aa58b4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java @@ -0,0 +1,36 @@ +package net.hostsharing.hsadminng.hash; + +import com.sun.jna.Library; +import com.sun.jna.Native; + +public class LinuxEtcShadowHashGenerator { + + public static String hash(final HashGenerator generator, final String payload) { + if (generator.getSalt() == null) { + throw new IllegalStateException("no salt given"); + } + + return NativeCryptLibrary.INSTANCE.crypt(payload, "$" + generator.getAlgorithm().enrichedSalt(generator.getSalt())); + } + + public static void verify(final String givenHash, final String payload) { + + final var parts = givenHash.split("\\$"); + if (parts.length < 3 || parts.length > 5) { + throw new IllegalArgumentException("hash with unknown hash method: " + givenHash); + } + + final var algorithm = HashGenerator.Algorithm.byPrefix(parts[1]); + final var salt = parts.length == 4 ? parts[2] : parts[2] + "$" + parts[3]; + final var calculatedHash = HashGenerator.using(algorithm).withSalt(salt).hash(payload); + if (!calculatedHash.equals(givenHash)) { + throw new IllegalArgumentException("invalid password"); + } + } + + public interface NativeCryptLibrary extends Library { + NativeCryptLibrary INSTANCE = Native.load("crypt", NativeCryptLibrary.class); + + String crypt(String password, String salt); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hash/MySQLNativePasswordHashGenerator.java b/src/main/java/net/hostsharing/hsadminng/hash/MySQLNativePasswordHashGenerator.java new file mode 100644 index 00000000..12eeed44 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/MySQLNativePasswordHashGenerator.java @@ -0,0 +1,35 @@ +package net.hostsharing.hsadminng.hash; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class MySQLNativePasswordHashGenerator { + + public static String hash(final HashGenerator generator, final String password) { + // TODO.impl: if a random salt is generated or not should be part of the algorithm definition +// if (generator.getSalt() != null) { +// throw new IllegalStateException("salt not supported"); +// } + + try { + final var sha1 = MessageDigest.getInstance("SHA-1"); + final var firstHash = sha1.digest(password.getBytes()); + final var secondHash = sha1.digest(firstHash); + return "*" + bytesToHex(secondHash).toUpperCase(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-1 algorithm not found", e); + } + } + + private static String bytesToHex(byte[] bytes) { + final var hexString = new StringBuilder(); + for (byte b : bytes) { + final var hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hash/PostgreSQLScramSHA256.java b/src/main/java/net/hostsharing/hsadminng/hash/PostgreSQLScramSHA256.java new file mode 100644 index 00000000..500909f1 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/PostgreSQLScramSHA256.java @@ -0,0 +1,61 @@ +package net.hostsharing.hsadminng.hash; + +import lombok.SneakyThrows; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.Charset; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; + +public class PostgreSQLScramSHA256 { + + private static final String PBKDF_2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256"; + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String SHA256 = "SHA-256"; + private static final int ITERATIONS = 4096; + public static final int KEY_LENGTH_IN_BITS = 256; + + private static final PostgreSQLScramSHA256 scram = new PostgreSQLScramSHA256(); + + @SneakyThrows + public static String hash(final HashGenerator generator, final String password) { + if (generator.getSalt() == null) { + throw new IllegalStateException("no salt given"); + } + + final byte[] salt = generator.getSalt().getBytes(Charset.forName("latin1")); // Base64.getEncoder().encode(generator.getSalt().getBytes()); + final byte[] saltedPassword = scram.generateSaltedPassword(password, salt); + final byte[] clientKey = scram.hmacSHA256(saltedPassword, "Client Key".getBytes()); + final byte[] storedKey = MessageDigest.getInstance(SHA256).digest(clientKey); + final byte[] serverKey = scram.hmacSHA256(saltedPassword, "Server Key".getBytes()); + + return "SCRAM-SHA-256${iterations}:{base64EncodedSalt}${base64EncodedStoredKey}:{base64EncodedServerKey}" + .replace("{iterations}", Integer.toString(ITERATIONS)) + .replace("{base64EncodedSalt}", base64(salt)) + .replace("{base64EncodedStoredKey}", base64(storedKey)) + .replace("{base64EncodedServerKey}", base64(serverKey)); + } + + private static String base64(final byte[] salt) { + return Base64.getEncoder().encodeToString(salt); + } + + private byte[] generateSaltedPassword(String password, byte[] salt) throws NoSuchAlgorithmException, InvalidKeySpecException { + final var spec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH_IN_BITS); + return SecretKeyFactory.getInstance(PBKDF_2_WITH_HMAC_SHA256).generateSecret(spec).getEncoded(); + } + + private byte[] hmacSHA256(byte[] key, byte[] message) + throws NoSuchAlgorithmException, InvalidKeyException { + final var mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(key, HMAC_SHA256)); + return mac.doFinal(message); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java new file mode 100644 index 00000000..6a288a44 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java @@ -0,0 +1,55 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; + +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +// a partial HsOfficeDebitorEntity to reduce the number of SQL queries to load the entity +@Entity +@Table(name = "hs_booking_debitor_xv") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DisplayAs("BookingDebitor") +public class HsBookingDebitorEntity implements Stringifyable { + + public static final String DEBITOR_NUMBER_TAG = "D-"; + + private static Stringify stringify = + stringify(HsBookingDebitorEntity.class, "booking-debitor") + .withIdProp(HsBookingDebitorEntity::toShortString) + .withProp(HsBookingDebitorEntity::getDefaultPrefix) + .quotedValues(false); + + @Id + private UUID uuid; + + @Column(name = "debitornumber") + private Integer debitorNumber; + + @Column(name = "defaultprefix", columnDefinition = "char(3) not null") + private String defaultPrefix; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return DEBITOR_NUMBER_TAG + debitorNumber; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java new file mode 100644 index 00000000..f69dd72f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java @@ -0,0 +1,14 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingDebitorRepository extends Repository { + + Optional findByUuid(UUID id); + + List findByDebitorNumber(int debitorNumber); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItem.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItem.java new file mode 100644 index 00000000..215f7d94 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItem.java @@ -0,0 +1,172 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType; +import io.hypersistence.utils.hibernate.type.range.Range; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; +import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; +import org.hibernate.annotations.Type; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PostLoad; +import jakarta.persistence.Transient; +import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static java.util.Collections.emptyMap; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.upperInclusiveFromPostgresDateRange; +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true) +public abstract class HsBookingItem implements Stringifyable, BaseEntity, PropertiesProvider { + + private static Stringify stringify = stringify(HsBookingItem.class) + .withProp(HsBookingItem::getType) + .withProp(HsBookingItem::getCaption) + .withProp(HsBookingItem::getProject) + .withProp(e -> e.getValidity().asString()) + .withProp(HsBookingItem::getResources) + .quotedValues(false); + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "projectuuid") + private HsBookingProjectRealEntity project; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parentitemuuid") + private HsBookingItemRealEntity parentItem; + + @NotNull + @Column(name = "type") + @Enumerated(EnumType.STRING) + private HsBookingItemType type; + + @Builder.Default + @Type(PostgreSQLRangeType.class) + @Column(name = "validity", columnDefinition = "daterange") + private Range validity = Range.closedInfinite(LocalDate.now()); + + @Column(name = "caption") + private String caption; + + @Builder.Default + @Setter(AccessLevel.NONE) + @Type(JsonType.class) + @Column(columnDefinition = "resources") + private Map resources = new HashMap<>(); + + @OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true) + @JoinColumn(name = "parentitemuuid", referencedColumnName = "uuid") + private List subBookingItems; + + @Transient + private PatchableMapWrapper resourcesWrapper; + + @Transient + private boolean isLoaded; + + @PostLoad + public void markAsLoaded() { + this.isLoaded = true; + } + + public PatchableMapWrapper getResources() { + return PatchableMapWrapper.of(resourcesWrapper, (newWrapper) -> {resourcesWrapper = newWrapper;}, resources); + } + + public void putResources(Map newResources) { + getResources().assign(newResources); + } + + public void setValidFrom(final LocalDate validFrom) { + setValidity(toPostgresDateRange(validFrom, getValidTo())); + } + + public void setValidTo(final LocalDate validTo) { + setValidity(toPostgresDateRange(getValidFrom(), validTo)); + } + + public LocalDate getValidFrom() { + return lowerInclusiveFromPostgresDateRange(getValidity()); + } + + public LocalDate getValidTo() { + return upperInclusiveFromPostgresDateRange(getValidity()); + } + + @Override + public PatchableMapWrapper directProps() { + return getResources(); + } + + @Override + public Object getContextValue(final String propName) { + final var v = resources.get(propName); + if (v != null) { + return v; + } + if (parentItem != null) { + return parentItem.getResources().get(propName); + } + return emptyMap(); + } + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return ofNullable(getRelatedProject()).map(HsBookingProject::toShortString).orElse("D-???????-?") + + ":" + caption; + } + + public HsBookingProject getRelatedProject() { + return project != null ? project + : parentItem != null ? parentItem.getRelatedProject() + : null; // can be the case for technical assets like IP-numbers + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java new file mode 100644 index 00000000..01d2e6a5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java @@ -0,0 +1,138 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; + +@RestController +public class HsBookingItemController implements HsBookingItemsApi { + + @Autowired + private Context context; + + @Autowired + private Mapper mapper; + + @Autowired + private HsBookingItemRbacRepository bookingItemRepo; + + @PersistenceContext + private EntityManager em; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listBookingItemsByProjectUuid( + final String currentUser, + final String assumedRoles, + final UUID projectUuid) { + context.define(currentUser, assumedRoles); + + final var entities = bookingItemRepo.findAllByProjectUuid(projectUuid); + + final var resources = mapper.mapList(entities, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addBookingItem( + final String currentUser, + final String assumedRoles, + final HsBookingItemInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = mapper.map(body, HsBookingItemRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + + final var saved = HsBookingItemEntityValidatorRegistry.validated(em, bookingItemRepo.save(entityToSave)); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/booking/items/{id}") + .buildAndExpand(saved.getUuid()) + .toUri(); + final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getBookingItemByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingItemUuid) { + + context.define(currentUser, assumedRoles); + + final var result = bookingItemRepo.findByUuid(bookingItemUuid); + result.ifPresent(entity -> em.detach(entity)); // prevent further LAZY-loading + return result + .map(bookingItemEntity -> ResponseEntity.ok( + mapper.map(bookingItemEntity, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @Override + @Transactional + public ResponseEntity deleteBookingIemByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingItemUuid) { + context.define(currentUser, assumedRoles); + + final var result = bookingItemRepo.deleteByUuid(bookingItemUuid); + return result == 0 + ? ResponseEntity.notFound().build() + : ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchBookingItem( + final String currentUser, + final String assumedRoles, + final UUID bookingItemUuid, + final HsBookingItemPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = bookingItemRepo.findByUuid(bookingItemUuid).orElseThrow(); + + new HsBookingItemEntityPatcher(current).apply(body); + + final var saved = bookingItemRepo.save(HsBookingItemEntityValidatorRegistry.validated(em, current)); + final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(mapped); + } + + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + resource.setValidFrom(entity.getValidity().lower()); + if (entity.getValidity().hasUpperBound()) { + resource.setValidTo(entity.getValidity().upper().minusDays(1)); + } + }; + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo())); + entity.putResources(KeyValueMap.from(resource.getResources())); + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcher.java new file mode 100644 index 00000000..13d11466 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcher.java @@ -0,0 +1,28 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource; +import net.hostsharing.hsadminng.mapper.EntityPatcher; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.mapper.OptionalFromJson; + +import java.util.Optional; + + +public class HsBookingItemEntityPatcher implements EntityPatcher { + + private final HsBookingItem entity; + + public HsBookingItemEntityPatcher(final HsBookingItem entity) { + this.entity = entity; + } + + @Override + public void apply(final HsBookingItemPatchResource resource) { + OptionalFromJson.of(resource.getCaption()) + .ifPresent(entity::setCaption); + Optional.ofNullable(resource.getResources()) + .ifPresent(r -> entity.getResources().patch(KeyValueMap.from(resource.getResources()))); + OptionalFromJson.of(resource.getValidTo()) + .ifPresent(entity::setValidTo); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacEntity.java new file mode 100644 index 00000000..5bd7b15d --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacEntity.java @@ -0,0 +1,83 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.io.IOException; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NULLABLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "hs_booking_item_rv") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +@AttributeOverrides({ + @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) +}) +public class HsBookingItemRbacEntity extends HsBookingItem { + + public static RbacView rbac() { + return rbacViewFor("bookingItem", HsBookingItemRbacEntity.class) + .withIdentityView(SQL.projection("caption")) + .withRestrictedViewOrderBy(SQL.expression("validity")) + .withUpdatableColumns("version", "caption", "validity", "resources") + .toRole("global", ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data? + .toRole("global", ADMIN).grantPermission(DELETE) + + .importEntityAlias("project", HsBookingProject.class, usingDefaultCase(), + dependsOnColumn("projectUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("project", ADMIN).grantPermission(INSERT) + + .importEntityAlias("parentItem", HsBookingItemRbacEntity.class, usingDefaultCase(), + dependsOnColumn("parentItemUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("parentItem", ADMIN).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("project", AGENT); + with.incomingSuperRole("parentItem", AGENT); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("project", TENANT); + with.outgoingSubRole("parentItem", TENANT); + with.permission(SELECT); + }) + + .limitDiagramTo("bookingItem", "project", "global"); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("6-hs-booking/630-booking-item/6303-hs-booking-item-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacRepository.java new file mode 100644 index 00000000..8c230445 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRbacRepository.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingItemRbacRepository extends HsBookingItemRepository, + Repository { + + Optional findByUuid(final UUID bookingItemUuid); + + List findByCaption(String bookingItemCaption); + + List findAllByProjectUuid(final UUID projectItemUuid); + + HsBookingItemRbacEntity save(HsBookingItemRbacEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealEntity.java new file mode 100644 index 00000000..c9e0f8de --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealEntity.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + + +@Entity +@Table(name = "hs_booking_item") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +@AttributeOverrides({ + @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) +})public class HsBookingItemRealEntity extends HsBookingItem { +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealRepository.java new file mode 100644 index 00000000..d9c509cc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRealRepository.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingItemRealRepository extends HsBookingItemRepository, + Repository { + + Optional findByUuid(final UUID bookingItemUuid); + + List findByCaption(String bookingItemCaption); + + List findAllByProjectUuid(final UUID projectItemUuid); + + HsBookingItemRealEntity save(HsBookingItemRealEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java new file mode 100644 index 00000000..98ba547c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java @@ -0,0 +1,20 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingItemRepository { + + Optional findByUuid(final UUID bookingItemUuid); + + List findByCaption(String bookingItemCaption); + + List findAllByProjectUuid(final UUID projectItemUuid); + + E save(E current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java new file mode 100644 index 00000000..55ff8ede --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java @@ -0,0 +1,42 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import java.util.List; +import java.util.Set; + +import static java.util.Optional.ofNullable; + +public enum HsBookingItemType implements Node { + PRIVATE_CLOUD, + CLOUD_SERVER(PRIVATE_CLOUD), + MANAGED_SERVER(PRIVATE_CLOUD), + MANAGED_WEBSPACE(MANAGED_SERVER), + DOMAIN_SETUP; + + private final HsBookingItemType parentItemType; + + HsBookingItemType() { + this.parentItemType = null; + } + + HsBookingItemType(final HsBookingItemType parentItemType) { + this.parentItemType = parentItemType; + } + + @Override + public List edges(final Set inGroups) { + return ofNullable(parentItemType) + .map(p -> (nodeName() + " *--> " + p.nodeName())) + .stream().toList(); + } + + @Override + public boolean belongsToAny(final Set groups) { + return true; // we currently do not filter booking item types + } + + @Override + public String nodeName() { + return "BI_" + name(); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java new file mode 100644 index 00000000..139fa05f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java @@ -0,0 +1,11 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import java.util.List; +import java.util.Set; + +public interface Node { + + String nodeName(); + boolean belongsToAny(Set groups); + List edges(final Set inGroup); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java new file mode 100644 index 00000000..8176464e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java @@ -0,0 +1,88 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.ValidatableProperty; +import org.apache.commons.lang3.BooleanUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; + +public class HsBookingItemEntityValidator extends HsEntityValidator { + + public HsBookingItemEntityValidator(final ValidatableProperty... properties) { + super(properties); + } + + @Override + public List validateEntity(final HsBookingItem bookingItem) { + // TODO.impl: HsBookingItemType could do this similar to HsHostingAssetType + if ( bookingItem.getParentItem() == null && bookingItem.getProject() == null) { + return List.of(bookingItem + ".'parentItem' or .'project' expected to be set, but both are null"); + } + return enrich(prefix(bookingItem.toShortString(), "resources"), super.validateProperties(bookingItem)); + } + + @Override + public List validateContext(final HsBookingItem bookingItem) { + return sequentiallyValidate( + () -> optionallyValidate(bookingItem.getParentItem()), + () -> validateAgainstSubEntities(bookingItem) + ); + } + + private static List optionallyValidate(final HsBookingItem bookingItem) { + return bookingItem != null + ? enrich(prefix(bookingItem.toShortString(), ""), + HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + : emptyList(); + } + + protected List validateAgainstSubEntities(final HsBookingItem bookingItem) { + return enrich(prefix(bookingItem.toShortString(), "resources"), + Stream.concat( + stream(propertyValidators) + .map(propDef -> propDef.validateTotals(bookingItem)) + .flatMap(Collection::stream), + stream(propertyValidators) + .filter(ValidatableProperty::isTotalsValidator) + .map(prop -> validateMaxTotalValue(bookingItem, prop)) + ).filter(Objects::nonNull).toList()); + } + + // TODO.refa: convert into generic shape like multi-options validator + private static String validateMaxTotalValue( + final HsBookingItem bookingItem, + final ValidatableProperty propDef) { + final var propName = propDef.propertyName(); + final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse(""); + final var totalValue = ofNullable(bookingItem.getSubBookingItems()).orElse(emptyList()) + .stream() + .map(subItem -> propDef.getValue(subItem.getResources())) + .map(HsBookingItemEntityValidator::convertBooleanToInteger) + .map(HsBookingItemEntityValidator::toIntegerWithDefault0) + .reduce(0, Integer::sum); + final var maxValue = getIntegerValueWithDefault0(propDef, bookingItem.getResources()); + if (propDef.thresholdPercentage() != null ) { + return totalValue > (maxValue * propDef.thresholdPercentage() / 100) + ? "%s' maximum total is %d%s, but actual total %s is %d%s, which exceeds threshold of %d%%" + .formatted(propName, maxValue, propUnit, propName, totalValue, propUnit, propDef.thresholdPercentage()) + : null; + } else { + return totalValue > maxValue + ? "%s' maximum total is %d%s, but actual total %s is %d%s" + .formatted(propName, maxValue, propUnit, propName, totalValue, propUnit) + : null; + } + } + + private static Object convertBooleanToInteger(final Object value) { + return value instanceof Boolean ? BooleanUtils.toInteger((Boolean)value) : value; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java new file mode 100644 index 00000000..8bfe12fd --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java @@ -0,0 +1,62 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.errors.MultiValidationException; + +import jakarta.persistence.EntityManager; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Arrays.stream; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; + +public class HsBookingItemEntityValidatorRegistry { + + private static final Map, HsEntityValidator> validators = new HashMap<>(); + static { + register(PRIVATE_CLOUD, new HsPrivateCloudBookingItemValidator()); + register(CLOUD_SERVER, new HsCloudServerBookingItemValidator()); + register(MANAGED_SERVER, new HsManagedServerBookingItemValidator()); + register(MANAGED_WEBSPACE, new HsManagedWebspaceBookingItemValidator()); + register(DOMAIN_SETUP, new HsDomainSetupBookingItemValidator()); + } + + private static void register(final Enum type, final HsEntityValidator validator) { + stream(validator.propertyValidators).forEach( entry -> { + entry.verifyConsistency(Map.entry(type, validator)); + }); + validators.put(type, validator); + } + + public static HsEntityValidator forType(final Enum type) { + if ( validators.containsKey(type)) { + return validators.get(type); + } + throw new IllegalArgumentException("no validator found for type " + type); + } + + public static Set> types() { + return validators.keySet(); + } + + public static List doValidate(final EntityManager em, final HsBookingItem bookingItem) { + return HsEntityValidator.doWithEntityManager(em, () -> + HsEntityValidator.sequentiallyValidate( + () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateEntity(bookingItem), + () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + ); + } + + public static E validated(final EntityManager em, final E entityToSave) { + MultiValidationException.throwIfNotEmpty(doValidate(em, entityToSave)); + return entityToSave; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java new file mode 100644 index 00000000..41fea174 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsCloudServerBookingItemValidator extends HsBookingItemEntityValidator { + + HsCloudServerBookingItemValidator() { + super( + // @formatter:off + booleanProperty("active") .withDefault(true), + + integerProperty("CPU") .min( 1) .max( 32) .required(), + integerProperty("RAM").unit("GB") .min( 1) .max( 8192) .required(), + integerProperty("SSD").unit("GB") .min( 25) .max( 1000) .step(25).requiresAtLeastOneOf("SDD", "HDD"), + integerProperty("HDD").unit("GB") .min(250) .max( 4000) .step(250).requiresAtLeastOneOf("SSD", "HDD"), + integerProperty("Traffic").unit("GB") .min(250) .max(10000) .step(250).requiresAtMaxOneOf("Bandwidth", "Traffic"), + integerProperty("Bandwidth").unit("GB") .min(250) .max(10000) .step(250).requiresAtMaxOneOf("Bandwidth", "Traffic"), // TODO.spec + + enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional() + // @formatter:on + ); + + // (q) We do have pre-existing CloudServers without SSD, just HDD, thus SSD starts with min=0. + // TODO.impl: Validation that SSD+HDD is at minimum 25 GB is missing. + // e.g. validationGroup("SSD", "HDD").min(0); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java new file mode 100644 index 00000000..c9fd731a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java @@ -0,0 +1,60 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; + +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator { + + public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(? validateEntity(final HsBookingItem bookingItem) { + final var violations = new ArrayList(); + final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); + if (!bookingItem.isLoaded() && + domainName.matches("hostsharing.(com|net|org|coop|de)")) { + violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName + + "' is a forbidden Hostsharing domain name"); + } + violations.addAll(super.validateEntity(bookingItem)); + return violations; + } + + private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) { + final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + final var secureRandom = new SecureRandom(); + final var sb = new StringBuilder(); + for (int i = 0; i < 40; ++i) { + if ( i > 0 && i % 4 == 0 ) { + sb.append("-"); + } + sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length()))); + } + return sb.toString(); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java new file mode 100644 index 00000000..67cae520 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + + + +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedServerBookingItemValidator extends HsBookingItemEntityValidator { + + HsManagedServerBookingItemValidator() { + super( + integerProperty("CPU").min(1).max(32).required(), + integerProperty("RAM").unit("GB").min(1).max(128).required(), + integerProperty("SSD").unit("GB").min(25).max(2000).step(25).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit().withThreshold(200), + integerProperty("HDD").unit("GB").min(250).max(10000).step(250).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit().withThreshold(200), + integerProperty("Traffic").unit("GB").min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit().withThreshold(200), + integerProperty("Bandwidth").unit("GB").min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit().withThreshold(200), // TODO.spec + enumerationProperty("SLA-Platform").values("BASIC", "EXT8H", "EXT4H", "EXT2H").withDefault("BASIC"), + booleanProperty("SLA-EMail").falseIf("SLA-Platform", "BASIC").withDefault(false), + booleanProperty("SLA-Maria").falseIf("SLA-Platform", "BASIC").optional(), + booleanProperty("SLA-PgSQL").falseIf("SLA-Platform", "BASIC").optional(), + booleanProperty("SLA-Office").falseIf("SLA-Platform", "BASIC").optional(), + booleanProperty("SLA-Web").falseIf("SLA-Platform", "BASIC").optional() + ); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java new file mode 100644 index 00000000..a3248439 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java @@ -0,0 +1,115 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.IntegerProperty; +import org.apache.commons.lang3.function.TriFunction; + +import java.util.List; +import java.util.Optional; + +import static java.util.Collections.emptyList; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE; +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.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedWebspaceBookingItemValidator extends HsBookingItemEntityValidator { + + public HsManagedWebspaceBookingItemValidator() { + super( + integerProperty("SSD").unit("GB").min(1).max(2000).step(1).required(), + integerProperty("HDD").unit("GB").min(0).max(10000).step(10).optional(), + integerProperty("Traffic").unit("GB").min(10).max(64000).step(10).requiresAtMaxOneOf("Bandwidth", "Traffic"), + integerProperty("Bandwidth").unit("GB").min(10).max(1000).step(10).requiresAtMaxOneOf("Bandwidth", "Traffic"), // TODO.spec + integerProperty("Multi").min(1).max(100).step(1).withDefault(1) + .eachComprising( 25, unixUsers()) + .eachComprising( 5, databaseUsers()) + .eachComprising( 5, databases()) + .eachComprising(250, eMailAddresses()), + integerProperty("Daemons").min(0).max(16).withDefault(0), + booleanProperty("Online Office Server").optional(), // TODO.impl: shorten to "Office" + enumerationProperty("SLA-Platform").values("BASIC", "EXT24H").withDefault("BASIC") + ); + } + + private static TriFunction, Integer, List> unixUsers() { + return (final HsBookingItem entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = fetchRelatedBookingItem(entity) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType() == UNIX_USER) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " unix users, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction, Integer, List> databaseUsers() { + return (final HsBookingItem entity, final IntegerProperty prop, final Integer factor) -> { + final var dbUserCount = fetchRelatedBookingItem(entity) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType() == PGSQL_USER || bi.getType() == MARIADB_USER ) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (dbUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " database users, but " + dbUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction, Integer, List> databases() { + return (final HsBookingItem entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = fetchRelatedBookingItem(entity) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType()==PGSQL_USER || bi.getType()==MARIADB_USER ) + .flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType()==PGSQL_DATABASE || subAsset.getType()==MARIADB_DATABASE)) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction, Integer, List> eMailAddresses() { + return (final HsBookingItem entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = fetchRelatedBookingItem(entity) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType() == DOMAIN_MBOX_SETUP) + .flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType()==EMAIL_ADDRESS)) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } + + private static Optional fetchRelatedBookingItem(final HsBookingItem entity) { + // TODO.perf: maybe we need to cache the result at least for a single valiationrun + return HsEntityValidator.localEntityManager.get().createQuery( + "SELECT asset FROM HsHostingAssetRealEntity asset WHERE asset.bookingItem.uuid=:bookingItemUuid", + HsHostingAssetRealEntity.class) + .setParameter("bookingItemUuid", entity.getUuid()) + .getResultStream().findFirst(); // there are 0 or 1, never more + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java new file mode 100644 index 00000000..e0e54f1e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java @@ -0,0 +1,41 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsPrivateCloudBookingItemValidator extends HsBookingItemEntityValidator { + + HsPrivateCloudBookingItemValidator() { + super( + // @formatter:off + integerProperty("CPU") .min( 1).max( 128).required().asTotalLimit(), + integerProperty("RAM").unit("GB") .min( 1).max( 512).required().asTotalLimit(), + integerProperty("SSD").unit("GB") .min( 25).max( 4000).step(25).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit(), + integerProperty("HDD").unit("GB") .min(250).max(16000).step(250).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit(), + integerProperty("Traffic").unit("GB") .min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit(), + integerProperty("Bandwidth").unit("GB") .min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit(), // TODO.spec + +// Alternatively we could specify it similarly to "Multi" option but exclusively counting: +// integerProperty("Resource-Points") .min(4).max(100).required() +// .each("CPU").countsAs(64) +// .each("RAM").countsAs(64) +// .each("SSD").countsAs(18) +// .each("HDD").countsAs(2) +// .each("Traffic").countsAs(1), + + integerProperty("SLA-Infrastructure EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT8H"), + integerProperty("SLA-Infrastructure EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT4H"), + integerProperty("SLA-Infrastructure EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT2H"), + + integerProperty("SLA-Platform EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT8H"), + integerProperty("SLA-Platform EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT4H"), + integerProperty("SLA-Platform EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT2H"), + + integerProperty("SLA-EMail") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Maria") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-PgSQL") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Office") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Web") .min( 0).max( 20).withDefault(0).asTotalLimit() + // @formatter:on + ); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProject.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProject.java new file mode 100644 index 00000000..6c109ef5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProject.java @@ -0,0 +1,114 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.*; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; + +import jakarta.persistence.*; +import java.io.IOException; +import java.util.UUID; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true) +public abstract class HsBookingProject implements Stringifyable, BaseEntity { + + private static Stringify stringify = stringify(HsBookingProject.class) + .withProp(HsBookingProject::getDebitor) + .withProp(HsBookingProject::getCaption) + .quotedValues(false); + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(optional = false) + @JoinColumn(name = "debitoruuid") + private HsBookingDebitorEntity debitor; + + @Column(name = "caption") + private String caption; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return ofNullable(debitor).map(HsBookingDebitorEntity::toShortString).orElse("D-???????") + + ":" + caption; + } + + public static RbacView rbac() { + return rbacViewFor("project", HsBookingProject.class) + .withIdentityView(SQL.query(""" + SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingProject.caption) as idName + FROM hs_booking_project bookingProject + JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid + """)) + .withRestrictedViewOrderBy(SQL.expression("caption")) + .withUpdatableColumns("version", "caption") + + .importEntityAlias("debitor", HsOfficeDebitorEntity.class, usingDefaultCase(), + dependsOnColumn("debitorUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .importEntityAlias("debitorRel", HsOfficeRelationRbacEntity.class, usingCase(DEBITOR), + dependsOnColumn("debitorUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = ${REF}.debitorUuid + """), + NOT_NULL) + .toRole("debitorRel", ADMIN).grantPermission(INSERT) + .toRole("global", ADMIN).grantPermission(DELETE) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("debitorRel", AGENT).unassumed(); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("debitorRel", TENANT); + with.permission(SELECT); + }) + + .limitDiagramTo("project", "debitorRel", "global"); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("6-hs-booking/620-booking-project/6203-hs-booking-project-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java new file mode 100644 index 00000000..9247ff83 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java @@ -0,0 +1,128 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjectsApi; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectInsertResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectResource; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; + +@RestController +public class HsBookingProjectController implements HsBookingProjectsApi { + + @Autowired + private Context context; + + @Autowired + private Mapper mapper; + + @Autowired + private HsBookingProjectRbacRepository bookingProjectRepo; + + @Autowired + private HsBookingDebitorRepository debitorRepo; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listBookingProjectsByDebitorUuid( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid) { + context.define(currentUser, assumedRoles); + + final var entities = bookingProjectRepo.findAllByDebitorUuid(debitorUuid); + + final var resources = mapper.mapList(entities, HsBookingProjectResource.class); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addBookingProject( + final String currentUser, + final String assumedRoles, + final HsBookingProjectInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = mapper.map(body, HsBookingProjectRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + + final var saved = bookingProjectRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/booking/projects/{id}") + .buildAndExpand(saved.getUuid()) + .toUri(); + final var mapped = mapper.map(saved, HsBookingProjectResource.class); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getBookingProjectByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid) { + + context.define(currentUser, assumedRoles); + + final var result = bookingProjectRepo.findByUuid(bookingProjectUuid); + return result + .map(bookingProjectEntity -> ResponseEntity.ok( + mapper.map(bookingProjectEntity, HsBookingProjectResource.class))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @Override + @Transactional + public ResponseEntity deleteBookingIemByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid) { + context.define(currentUser, assumedRoles); + + final var result = bookingProjectRepo.deleteByUuid(bookingProjectUuid); + return result == 0 + ? ResponseEntity.notFound().build() + : ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchBookingProject( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid, + final HsBookingProjectPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = bookingProjectRepo.findByUuid(bookingProjectUuid).orElseThrow(); + + new HsBookingProjectEntityPatcher(current).apply(body); + + final var saved = bookingProjectRepo.save(current); + final var mapped = mapper.map(saved, HsBookingProjectResource.class); + return ResponseEntity.ok(mapped); + } + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + if (resource.getDebitorUuid() != null) { + entity.setDebitor(debitorRepo.findByUuid(resource.getDebitorUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] debitorUuid %s not found".formatted( + resource.getDebitorUuid())))); + } + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java new file mode 100644 index 00000000..e6ddcc6e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.mapper.EntityPatcher; +import net.hostsharing.hsadminng.mapper.OptionalFromJson; + + + +public class HsBookingProjectEntityPatcher implements EntityPatcher { + + private final HsBookingProject entity; + + public HsBookingProjectEntityPatcher(final HsBookingProject entity) { + this.entity = entity; + } + + @Override + public void apply(final HsBookingProjectPatchResource resource) { + OptionalFromJson.of(resource.getCaption()) + .ifPresent(entity::setCaption); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java new file mode 100644 index 00000000..50ba366a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java @@ -0,0 +1,86 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.io.IOException; + +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "hs_booking_project_rv") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +public class HsBookingProjectRbacEntity extends HsBookingProject { + + public static RbacView rbac() { + return rbacViewFor("project", HsBookingProjectRbacEntity.class) + .withIdentityView(SQL.query(""" + SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingProject.caption) as idName + FROM hs_booking_project bookingProject + JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid + """)) + .withRestrictedViewOrderBy(SQL.expression("caption")) + .withUpdatableColumns("version", "caption") + + .importEntityAlias("debitor", HsOfficeDebitorEntity.class, usingDefaultCase(), + dependsOnColumn("debitorUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .importEntityAlias("debitorRel", HsOfficeRelationRbacEntity.class, usingCase(DEBITOR), + dependsOnColumn("debitorUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = ${REF}.debitorUuid + """), + NOT_NULL) + .toRole("debitorRel", ADMIN).grantPermission(INSERT) + .toRole("global", ADMIN).grantPermission(DELETE) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("debitorRel", AGENT).unassumed(); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("debitorRel", TENANT); + with.permission(SELECT); + }) + + .limitDiagramTo("project", "debitorRel", "global"); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("6-hs-booking/620-booking-project/6203-hs-booking-project-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacRepository.java new file mode 100644 index 00000000..8541e002 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacRepository.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingProjectRbacRepository extends HsBookingProjectRepository, + Repository { + + Optional findByUuid(final UUID bookingProjectUuid); + List findByCaption(final String projectCaption); + + List findAllByDebitorUuid(final UUID bookingProjectUuid); + + HsBookingProjectRbacEntity save(HsBookingProjectRbacEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealEntity.java new file mode 100644 index 00000000..e561c0b6 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealEntity.java @@ -0,0 +1,19 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + + +@Entity +@Table(name = "hs_booking_project") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +public class HsBookingProjectRealEntity extends HsBookingProject { +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealRepository.java new file mode 100644 index 00000000..b6e74d62 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRealRepository.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingProjectRealRepository extends HsBookingProjectRepository, + Repository { + + Optional findByUuid(final UUID bookingProjectUuid); + List findByCaption(final String projectCaption); + + List findAllByDebitorUuid(final UUID bookingProjectUuid); + + HsBookingProjectRealEntity save(HsBookingProjectRealEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java new file mode 100644 index 00000000..a609f625 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java @@ -0,0 +1,19 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingProjectRepository { + + Optional findByUuid(final UUID bookingProjectUuid); + List findByCaption(final String projectCaption); + + List findAllByDebitorUuid(final UUID bookingProjectUuid); + + E save(E current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAsset.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAsset.java new file mode 100644 index 00000000..52e884e1 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAsset.java @@ -0,0 +1,166 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.Getter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; +import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; +import org.hibernate.annotations.Type; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PostLoad; +import jakarta.persistence.Transient; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Collections.emptyMap; +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true) +public abstract class HsHostingAsset implements Stringifyable, BaseEntity, PropertiesProvider { + + static Stringify stringify = stringify(HsHostingAsset.class) + .withProp(HsHostingAsset::getType) + .withProp(HsHostingAsset::getIdentifier) + .withProp(HsHostingAsset::getCaption) + .withProp(HsHostingAsset::getParentAsset) + .withProp(HsHostingAsset::getAssignedToAsset) + .withProp(HsHostingAsset::getBookingItem) + .withProp(HsHostingAsset::getConfig) + .quotedValues(false); + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bookingitemuuid") + private HsBookingItemRealEntity bookingItem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parentassetuuid") + private HsHostingAssetRealEntity parentAsset; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignedtoassetuuid") + private HsHostingAssetRealEntity assignedToAsset; + + @Column(name = "type") + @Enumerated(EnumType.STRING) + private HsHostingAssetType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "alarmcontactuuid") + private HsOfficeContactRealEntity alarmContact; + + @Builder.Default + @OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "parentassetuuid", referencedColumnName = "uuid") + private List subHostingAssets = new ArrayList<>(); + + @Column(name = "identifier") + private String identifier; // e.g. vm1234, xyz00, example.org, xyz00_abc + + @Column(name = "caption") + private String caption; + + @Builder.Default + @Setter(AccessLevel.NONE) + @Type(JsonType.class) + @Column(columnDefinition = "config") + private Map config = new HashMap<>(); + + @Transient + private PatchableMapWrapper configWrapper; + + @Transient + private boolean isLoaded; + + @PostLoad + public void markAsLoaded() { + this.isLoaded = true; + } + + public PatchableMapWrapper getConfig() { + return PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper;}, config); + } + + public void putConfig(Map newConfig) { + PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper;}, config).assign(newConfig); + } + + @Override + public PatchableMapWrapper directProps() { + return getConfig(); + } + + public HsBookingProject getRelatedProject() { + return Optional.ofNullable(getBookingItem()) + .map(HsBookingItem::getRelatedProject) + .orElseGet(() -> Optional.ofNullable(getParentAsset()) + .map(HsHostingAsset::getRelatedProject) + .orElse(null)); + } + + @Override + public Object getContextValue(final String propName) { + final var v = directProps().get(propName); + if (v != null) { + return v; + } + + if (getBookingItem() != null) { + return getBookingItem().getResources().get(propName); + } + if (getParentAsset() != null && getParentAsset().getBookingItem() != null) { + return getParentAsset().getBookingItem().getResources().get(propName); + } + return emptyMap(); + } + + @Override + public String toShortString() { + return getType() + ":" + getIdentifier(); + } + + @Override + public String toString() { + return stringify.apply(this); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java new file mode 100644 index 00000000..26636eb4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java @@ -0,0 +1,168 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetInsertResource; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiConsumer; + +@RestController +public class HsHostingAssetController implements HsHostingAssetsApi { + + @Autowired + private EntityManagerWrapper emw; + + @Autowired + private Context context; + + @Autowired + private Mapper mapper; + + @Autowired + private HsHostingAssetRbacRepository rbacAssetRepo; + + @Autowired + private HsHostingAssetRealRepository realAssetRepo; + + @Autowired + private HsBookingItemRealRepository realBookingItemRepo; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listAssets( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid, + final UUID parentAssetUuid, + final HsHostingAssetTypeResource type) { + context.define(currentUser, assumedRoles); + + final var entities = rbacAssetRepo.findAllByCriteria(debitorUuid, parentAssetUuid, HsHostingAssetType.of(type)); + + final var resources = mapper.mapList(entities, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resources); + } + + + @Override + @Transactional + public ResponseEntity addAsset( + final String currentUser, + final String assumedRoles, + final HsHostingAssetInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entity = mapper.map(body, HsHostingAssetRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + + final var mapped = new HostingAssetEntitySaveProcessor(emw, entity) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .save() + .validateContext() + .mapUsing(e -> mapper.map(e, HsHostingAssetResource.class)) + .revampProperties(); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/hosting/assets/{id}") + .buildAndExpand(mapped.getUuid()) + .toUri(); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getAssetByUuid( + final String currentUser, + final String assumedRoles, + final UUID assetUuid) { + + context.define(currentUser, assumedRoles); + + final var result = rbacAssetRepo.findByUuid(assetUuid); + return result + .map(assetEntity -> ResponseEntity.ok( + mapper.map(assetEntity, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @Override + @Transactional + public ResponseEntity deleteAssetUuid( + final String currentUser, + final String assumedRoles, + final UUID assetUuid) { + context.define(currentUser, assumedRoles); + + final var result = rbacAssetRepo.deleteByUuid(assetUuid); + return result == 0 + ? ResponseEntity.notFound().build() + : ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchAsset( + final String currentUser, + final String assumedRoles, + final UUID assetUuid, + final HsHostingAssetPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var entity = rbacAssetRepo.findByUuid(assetUuid).orElseThrow(); + + new HsHostingAssetEntityPatcher(emw, entity).apply(body); + + final var mapped = new HostingAssetEntitySaveProcessor(emw, entity) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .save() + .validateContext() + .mapUsing(e -> mapper.map(e, HsHostingAssetResource.class)) + .revampProperties(); + + return ResponseEntity.ok(mapped); + } + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.putConfig(KeyValueMap.from(resource.getConfig())); + if (resource.getBookingItemUuid() != null) { + entity.setBookingItem(realBookingItemRepo.findByUuid(resource.getBookingItemUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] bookingItemUuid %s not found".formatted( + resource.getBookingItemUuid())))); + } + if (resource.getParentAssetUuid() != null) { + entity.setParentAsset(realAssetRepo.findByUuid(resource.getParentAssetUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] parentAssetUuid %s not found".formatted( + resource.getParentAssetUuid())))); + } + }; + + @SuppressWarnings("unchecked") + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) + -> resource.setConfig(HostingAssetEntityValidatorRegistry.forType(entity.getType()) + .revampProperties(emw, entity, (Map) resource.getConfig())); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java new file mode 100644 index 00000000..5296a955 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java @@ -0,0 +1,35 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.mapper.EntityPatcher; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.mapper.OptionalFromJson; + +import jakarta.persistence.EntityManager; +import java.util.Optional; + +public class HsHostingAssetEntityPatcher implements EntityPatcher { + + private final EntityManager em; + private final HsHostingAssetRbacEntity entity; + + public HsHostingAssetEntityPatcher(final EntityManager em, final HsHostingAssetRbacEntity entity) { + this.em = em; + this.entity = entity; + } + + @Override + public void apply(final HsHostingAssetPatchResource resource) { + OptionalFromJson.of(resource.getCaption()) + .ifPresent(entity::setCaption); + Optional.ofNullable(resource.getConfig()) + .ifPresent(r -> entity.getConfig().patch(KeyValueMap.from(resource.getConfig()))); + OptionalFromJson.of(resource.getAlarmContactUuid()) + // HOWTO: patch nullable JSON resource uuid to an ntity reference + .ifPresent(newValue -> entity.setAlarmContact( + Optional.ofNullable(newValue) + .map(uuid -> em.getReference(HsOfficeContactRealEntity.class, newValue)) + .orElse(null))); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java new file mode 100644 index 00000000..ca8bbb08 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java @@ -0,0 +1,40 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + + +@RestController +public class HsHostingAssetPropsController implements HsHostingAssetPropsApi { + + @Override + public ResponseEntity> listAssetTypes() { + final var resource = HostingAssetEntityValidatorRegistry.types().stream() + .map(Enum::name) + .toList(); + return ResponseEntity.ok(resource); + } + + @Override + public ResponseEntity> listAssetTypeProps( + final HsHostingAssetTypeResource assetType) { + + final Enum type = HsHostingAssetType.of(assetType); + final var propValidators = HostingAssetEntityValidatorRegistry.forType(type); + final List> resource = propValidators.properties(); + return ResponseEntity.ok(toListOfObjects(resource)); + } + + private List toListOfObjects(final List> resource) { + // OpenApi ony generates List not List> for the Java interface. + // But Spring properly converts the List of Maps, thus we can simply cast the type: + //noinspection rawtypes,unchecked + return (List) resource; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacEntity.java new file mode 100644 index 00000000..be568944 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacEntity.java @@ -0,0 +1,115 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.io.IOException; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef.inCaseOf; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NULLABLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.GUEST; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.REFERRER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "hs_hosting_asset_rv") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +public class HsHostingAssetRbacEntity extends HsHostingAsset { + + public static RbacView rbac() { + return rbacViewFor("asset", HsHostingAssetRbacEntity.class) + .withIdentityView(SQL.projection("identifier")) + .withRestrictedViewOrderBy(SQL.expression("identifier")) + .withUpdatableColumns("version", "caption", "config", "assignedToAssetUuid", "alarmContactUuid") + .toRole(GLOBAL, ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data? + + .importEntityAlias("bookingItem", HsBookingItem.class, usingDefaultCase(), + dependsOnColumn("bookingItemUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + + .importEntityAlias("parentAsset", HsHostingAssetRbacEntity.class, usingDefaultCase(), + dependsOnColumn("parentAssetUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("parentAsset", ADMIN).grantPermission(INSERT) + + .importEntityAlias("assignedToAsset", HsHostingAssetRbacEntity.class, usingDefaultCase(), + dependsOnColumn("assignedToAssetUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + + .importEntityAlias("alarmContact", HsOfficeContactRbacEntity.class, usingDefaultCase(), + dependsOnColumn("alarmContactUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + + .switchOnColumn( + "type", + inCaseOf("DOMAIN_SETUP", then -> { + then.toRole(GLOBAL, GUEST).grantPermission(INSERT); + }) + ) + + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN).unassumed(); // TODO.spec: replace by a better solution + with.incomingSuperRole("bookingItem", ADMIN); + with.incomingSuperRole("parentAsset", ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.incomingSuperRole("bookingItem", AGENT); + with.incomingSuperRole("parentAsset", AGENT); + with.permission(UPDATE); + }) + .createSubRole(AGENT, (with) -> { + with.incomingSuperRole("assignedToAsset", AGENT); // TODO.spec: or ADMIN? + with.outgoingSubRole("assignedToAsset", TENANT); + with.outgoingSubRole("alarmContact", REFERRER); + }) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("bookingItem", TENANT); + with.outgoingSubRole("parentAsset", TENANT); + with.incomingSuperRole("alarmContact", ADMIN); + with.permission(SELECT); + }) + + .limitDiagramTo( + "asset", + "bookingItem", + "bookingItem.debitorRel", + "parentAsset", + "assignedToAsset", + "alarmContact", + "global"); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacRepository.java new file mode 100644 index 00000000..c7f5aa34 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRbacRepository.java @@ -0,0 +1,47 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + + +public interface HsHostingAssetRbacRepository extends HsHostingAssetRepository, Repository { + + Optional findByUuid(final UUID serverUuid); + + List findByIdentifier(String assetIdentifier); + + @Query(value = """ + select ha.uuid, + ha.alarmcontactuuid, + ha.assignedtoassetuuid, + ha.bookingitemuuid, + ha.caption, + ha.config, + ha.identifier, + ha.parentassetuuid, + ha.type, + ha.version + from hs_hosting_asset_rv ha + left join hs_booking_item bi on bi.uuid = ha.bookingitemuuid + left join hs_hosting_asset pha on pha.uuid = ha.parentassetuuid + where (:projectUuid is null or bi.projectuuid=:projectUuid) + and (:parentAssetUuid is null or pha.uuid=:parentAssetUuid) + and (:type is null or :type=cast(ha.type as text)) + """, nativeQuery = true) + // The JPQL query did not generate "left join" but just "join". + // I also optimized the query by not using the _rv for hs_booking_item and hs_hosting_asset, only for hs_hosting_asset_rv. + List findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type); + default List findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) { + return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type)); + } + + HsHostingAssetRbacEntity save(HsHostingAsset current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealEntity.java new file mode 100644 index 00000000..a586f245 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealEntity.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "hs_hosting_asset") +@SuperBuilder(builderMethodName = "genericBuilder", toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +public class HsHostingAssetRealEntity extends HsHostingAsset { + + // without this wrapper method, the builder returns a generic entity which cannot resolved in a generic context + public static HsHostingAssetRealEntityBuilder builder() { + //noinspection unchecked + return (HsHostingAssetRealEntityBuilder) genericBuilder(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealRepository.java new file mode 100644 index 00000000..15a7de84 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRealRepository.java @@ -0,0 +1,46 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsHostingAssetRealRepository extends HsHostingAssetRepository, Repository { + + Optional findByUuid(final UUID serverUuid); + + List findByIdentifier(String assetIdentifier); + + @Query(value = """ + select ha.uuid, + ha.alarmcontactuuid, + ha.assignedtoassetuuid, + ha.bookingitemuuid, + ha.caption, + ha.config, + ha.identifier, + ha.parentassetuuid, + ha.type, + ha.version + from hs_hosting_asset_rv ha + left join hs_booking_item bi on bi.uuid = ha.bookingitemuuid + left join hs_hosting_asset pha on pha.uuid = ha.parentassetuuid + where (:projectUuid is null or bi.projectuuid=:projectUuid) + and (:parentAssetUuid is null or pha.uuid=:parentAssetUuid) + and (:type is null or :type=cast(ha.type as text)) + """, nativeQuery = true) + // The JPQL query did not generate "left join" but just "join". + // I also optimized the query by not using the _rv for hs_booking_item and hs_hosting_asset, only for hs_hosting_asset_rv. + List findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type); + default List findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) { + return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type)); + } + + HsHostingAssetRealEntity save(HsHostingAssetRealEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java new file mode 100644 index 00000000..8e062869 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsHostingAssetRepository { + + Optional findByUuid(final UUID serverUuid); + + List findByIdentifier(String assetIdentifier); + + List findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type); + + default List findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) { + return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type)); + } + + E save(HsHostingAsset current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java new file mode 100644 index 00000000..82076fc0 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java @@ -0,0 +1,444 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import lombok.AllArgsConstructor; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.Node; + +import javax.naming.NamingException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toSet; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.assignedTo; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.optionalParent; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.optionallyAssignedTo; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.requiredParent; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.requires; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.terminatory; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.OPTIONAL; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.REQUIRED; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.TERMINATORY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.ASSIGNED_TO_ASSET; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.PARENT_ASSET; + +public enum HsHostingAssetType implements Node { + SAME_TYPE, // pseudo-type for recursive references + + CLOUD_SERVER( // named e.g. vm1234 + inGroup("Server"), + requires(HsBookingItemType.CLOUD_SERVER)), + + MANAGED_SERVER( // named e.g. vm1234 + inGroup("Server"), + requires(HsBookingItemType.MANAGED_SERVER)), + + MANAGED_WEBSPACE( // named eg. xyz00 + inGroup("Webspace"), + requires(HsBookingItemType.MANAGED_WEBSPACE), + optionalParent(MANAGED_SERVER)), + + UNIX_USER( // named e.g. xyz00-abc + inGroup("Webspace"), + requiredParent(MANAGED_WEBSPACE)), + + // TODO.spec: do we really want to keep email aliases or migrate to unix users with .forward? + EMAIL_ALIAS( // named e.g. xyz00-abc + inGroup("Webspace"), + requiredParent(MANAGED_WEBSPACE)), + + DOMAIN_SETUP( // named e.g. example.org + inGroup("Domain"), + terminatory(HsBookingItemType.DOMAIN_SETUP), + optionalParent(SAME_TYPE) + ), + + DOMAIN_DNS_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), + + DOMAIN_HTTP_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(UNIX_USER)), + + DOMAIN_SMTP_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), + + DOMAIN_MBOX_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), + + // TODO.spec: SECURE_MX + + EMAIL_ADDRESS( // named e.g. sample@example.org + inGroup("Domain"), + requiredParent(DOMAIN_MBOX_SETUP)), + + PGSQL_INSTANCE( // TODO.spec: identifier to be specified + inGroup("PostgreSQL"), + requiredParent(MANAGED_SERVER)), // TODO.spec: or MANAGED_WEBSPACE? + + PGSQL_USER( // named e.g. xyz00_abc + inGroup("PostgreSQL"), + requiredParent(MANAGED_WEBSPACE), // thus, the MANAGED_WEBSPACE:Agent becomes RBAC owner + assignedTo(PGSQL_INSTANCE)), // keep in mind: no RBAC grants implied + + PGSQL_DATABASE( // named e.g. xyz00_abc + inGroup("PostgreSQL"), + requiredParent(PGSQL_USER)), // thus, the PGSQL_USER_USER:Agent becomes RBAC owner + + MARIADB_INSTANCE( // TODO.spec: identifier to be specified + inGroup("MariaDB"), + requiredParent(MANAGED_SERVER)), // TODO.spec: or MANAGED_WEBSPACE? + + MARIADB_USER( // named e.g. xyz00_abc + inGroup("MariaDB"), + requiredParent(MANAGED_WEBSPACE), // thus, the MANAGED_WEBSPACE:Agent becomes RBAC owner + assignedTo(MARIADB_INSTANCE)), + + MARIADB_DATABASE( // named e.g. xyz00_abc + inGroup("MariaDB"), + requiredParent(MARIADB_USER)), // thus, the MARIADB_USER:Agent becomes RBAC owner + + IPV4_NUMBER( + inGroup("Server"), + optionallyAssignedTo(CLOUD_SERVER).or(MANAGED_SERVER).or(MANAGED_WEBSPACE) + ), + + IPV6_NUMBER( + inGroup("Server"), + optionallyAssignedTo(CLOUD_SERVER).or(MANAGED_SERVER).or(MANAGED_WEBSPACE) + ); + + private final String groupName; + private final EntityTypeRelation[] relations; + + HsHostingAssetType( + final String groupName, + final EntityTypeRelation... relations + ) { + this.groupName = groupName; + this.relations = relations; + } + + HsHostingAssetType() { + this.groupName = null; + this.relations = null; + } + + /// just syntactic sugar + private static String inGroup(final String groupName) { + return groupName; + } + + // TODO.refa: try to get rid of the following similar methods: + + public RelationPolicy bookingItemPolicy() { + return stream(relations) + .filter(r -> r.relationType == BOOKING_ITEM) + .map(r -> r.relationPolicy) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(RelationPolicy.FORBIDDEN); + } + + public Set bookingItemTypes() { + return stream(relations) + .filter(r -> r.relationType == BOOKING_ITEM) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .map(r -> r.relatedTypes(this)) + .stream().flatMap(Set::stream) + .map(r -> (HsBookingItemType) r) + .collect(toSet()); + } + + public RelationPolicy parentAssetPolicy() { + return stream(relations) + .filter(r -> r.relationType == PARENT_ASSET) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .map(r -> r.relationPolicy) + .orElse(RelationPolicy.FORBIDDEN); + } + + public Set parentAssetTypes() { + return stream(relations) + .filter(r -> r.relationType == PARENT_ASSET) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .map(r -> r.relatedTypes(this)) + .stream().flatMap(Set::stream) + .map(r -> (HsHostingAssetType) r) + .collect(toSet()); + } + + public RelationPolicy assignedToAssetPolicy() { + return stream(relations) + .filter(r -> r.relationType == ASSIGNED_TO_ASSET) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .map(r -> r.relationPolicy) + .orElse(RelationPolicy.FORBIDDEN); + } + + public Set assignedToAssetTypes() { + return stream(relations) + .filter(r -> r.relationType == ASSIGNED_TO_ASSET) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .map(r -> r.relatedTypes(this)) + .stream().flatMap(Set::stream) + .map(r -> (HsHostingAssetType) r) + .collect(toSet()); + } + + private static X onlyASingleElementExpectedException(Object a, Object b) { + throw new IllegalStateException("Only a single element expected to match criteria."); + } + + @Override + public List edges(final Set inGroups) { + return stream(relations) + .map(r -> r.relatedTypes(this).stream() + .filter(x -> x.belongsToAny(inGroups)) + .map(x -> nodeName() + r.edge + x.nodeName()) + .toList()) + .flatMap(List::stream) + .sorted() + .toList(); + } + + @Override + public boolean belongsToAny(final Set groups) { + return groups.contains(this.groupName); + } + + @Override + public String nodeName() { + return "HA_" + name(); + } + + public static > HsHostingAssetType of(final T value) { + return value == null ? null : valueOf(value.name()); + } + + static String asString(final HsHostingAssetType type) { + return type == null ? null : type.name(); + } + + private static String renderAsPlantUML(final String caption, final Set includedHostingGroups) { + final String bookingNodes = stream(HsBookingItemType.values()) + .map(t -> " entity " + t.nodeName()) + .collect(joining("\n")); + final String hostingGroups = includedHostingGroups.stream().sorted() + .map(HsHostingAssetType::generateGroup) + .collect(joining("\n")); + final String hostingAssetNodes = stream(HsHostingAssetType.values()) + .filter(t -> t.isInGroups(includedHostingGroups)) + .map(t -> "entity " + t.nodeName()) + .collect(joining("\n")); + final String bookingItemEdges = stream(HsBookingItemType.values()) + .map(t -> t.edges(includedHostingGroups)) + .flatMap(Collection::stream) + .collect(joining("\n")); + final String hostingAssetEdges = stream(HsHostingAssetType.values()) + .filter(t -> t.isInGroups(includedHostingGroups)) + .map(t -> t.edges(includedHostingGroups)) + .flatMap(Collection::stream) + .collect(joining("\n")); + return """ + + ### %{caption} + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + %{bookingNodes} + } + + package Hosting #feb28c{ + %{hostingGroups} + } + + %{bookingItemEdges} + + %{hostingAssetEdges} + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + """ + .replace("%{caption}", caption) + .replace("%{bookingNodes}", bookingNodes) + .replace("%{hostingGroups}", hostingGroups) + .replace("%{hostingAssetNodeStyles}", hostingAssetNodes) + .replace("%{bookingItemEdges}", bookingItemEdges) + .replace("%{hostingAssetEdges}", hostingAssetEdges); + } + + private boolean isInGroups(final Set assetGroups) { + return groupName != null && assetGroups.contains(groupName); + } + + private static String generateGroup(final String group) { + return " package " + group + " #99bcdb {\n" + + stream(HsHostingAssetType.values()) + .filter(t -> group.equals(t.groupName)) + .map(t -> " entity " + t.nodeName()) + .collect(joining("\n")) + + "\n }\n"; + } + + static String renderAsEmbeddedPlantUml() { + + final var markdown = new StringBuilder(""" + ## HostingAsset Type Structure + + """); + + // rendering all types in a single diagram is currently ignored + renderAsPlantUML("Domain", stream(HsHostingAssetType.values()) + .filter(t -> t.groupName != null) + .map(t -> t.groupName) + .collect(toSet())); + + markdown + .append(renderAsPlantUML("Server+Webspace", Set.of("Server", "Webspace"))) + .append(renderAsPlantUML("Domain", Set.of("Domain", "Webspace"))) + .append(renderAsPlantUML("MariaDB", Set.of("MariaDB", "Webspace"))) + .append(renderAsPlantUML("PostgreSQL", Set.of("PostgreSQL", "Webspace"))); + + markdown.append(""" + + This code generated was by %{this}.main, do not amend manually. + """ + .replace("%{this}", HsHostingAssetType.class.getSimpleName())); + + return markdown.toString(); + } + + public static void main(final String[] args) throws IOException, NamingException { + Files.writeString( + Path.of("doc/hs-hosting-asset-type-structure.md"), + renderAsEmbeddedPlantUml(), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + public enum RelationPolicy { + FORBIDDEN, OPTIONAL, TERMINATORY, REQUIRED + } + + public enum RelationType { + BOOKING_ITEM, + PARENT_ASSET, + ASSIGNED_TO_ASSET + } +} + +@AllArgsConstructor +class EntityTypeRelation { + + final HsHostingAssetType.RelationPolicy relationPolicy; + final HsHostingAssetType.RelationType relationType; + final Function getter; + private final List acceptedRelatedTypes; + final String edge; + + private EntityTypeRelation( + final HsHostingAssetType.RelationPolicy relationPolicy, + final HsHostingAssetType.RelationType relationType, + final Function getter, + final T acceptedRelatedType, + final String edge + ) { + this(relationPolicy, relationType, getter, modifiyableListOf(acceptedRelatedType), edge); + } + + public Set relatedTypes(final HsHostingAssetType referringType) { + final Set result = acceptedRelatedTypes.stream() + .map(t -> t == HsHostingAssetType.SAME_TYPE ? referringType : t) + .collect(toSet()); + //noinspection unchecked + return (Set) result; + } + + static EntityTypeRelation terminatory(final HsBookingItemType bookingItemType) { + return new EntityTypeRelation<>( + TERMINATORY, + BOOKING_ITEM, + HsHostingAssetRbacEntity::getBookingItem, + bookingItemType, + " *..> "); + } + + static EntityTypeRelation requires(final HsBookingItemType bookingItemType) { + return new EntityTypeRelation<>( + REQUIRED, + BOOKING_ITEM, + HsHostingAssetRbacEntity::getBookingItem, + bookingItemType, + " *==> "); + } + + static EntityTypeRelation optionalParent(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>( + OPTIONAL, + PARENT_ASSET, + HsHostingAsset::getParentAsset, + hostingAssetType, + " o..> "); + } + + static EntityTypeRelation requiredParent(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>( + REQUIRED, + PARENT_ASSET, + HsHostingAsset::getParentAsset, + hostingAssetType, + " *==> "); + } + + static EntityTypeRelation assignedTo(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>( + REQUIRED, + ASSIGNED_TO_ASSET, + HsHostingAsset::getAssignedToAsset, + hostingAssetType, + " o--> "); + } + + EntityTypeRelation or(final T alternativeHostingAssetType) { + acceptedRelatedTypes.add(alternativeHostingAssetType); + return this; + } + + static EntityTypeRelation optionallyAssignedTo(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>( + OPTIONAL, + ASSIGNED_TO_ASSET, + HsHostingAsset::getAssignedToAsset, + hostingAssetType, + " o..> "); + } + + private static ArrayList modifiyableListOf(final T acceptedRelatedType) { + return new ArrayList<>(List.of(acceptedRelatedType)); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java new file mode 100644 index 00000000..037b95c0 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java @@ -0,0 +1,134 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.collections4.EnumerationUtils; + +import javax.naming.InvalidNameException; +import javax.naming.NameNotFoundException; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.ServiceUnavailableException; +import javax.naming.directory.Attribute; +import javax.naming.directory.InitialDirContext; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; + +public class Dns { + + public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of( + "[^.]+", // top-level-domains + "(co|org|gov|ac|sch)\\.uk", + "(com|net|org|edu|gov|asn|id)\\.au", + "(co|ne|or|ac|go)\\.jp", + "(com|net|org|gov|edu|ac)\\.cn", + "(com|net|org|gov|edu|mil|art)\\.br", + "(co|net|org|gen|firm|ind)\\.in", + "(com|net|org|gob|edu)\\.mx", + "(gov|edu)\\.it", + "(co|net|org|govt|ac|school|geek|kiwi)\\.nz", + "(co|ne|or|go|re|pe)\\.kr" + ); + public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS) + .map(Pattern::compile) + .toArray(Pattern[]::new); + + private final static Map fakeResults = new HashMap<>(); + + public static Optional superDomain(final String domainName) { + final var parts = domainName.split("\\.", 2); + if (parts.length == 2) { + return Optional.of(parts[1]); + } + return Optional.empty(); + } + + public static boolean isRegistrarLevelDomain(final String domainName) { + return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN) + .anyMatch(p -> p.matcher(domainName).matches()); + } + + /** + * @param domainName a fully qualified domain name + * @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself + */ + public static boolean isRegistrableDomain(final String domainName) { + return !isRegistrarLevelDomain(domainName) && + superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false); + } + + public static void fakeResultForDomain(final String domainName, final Result fakeResult) { + fakeResults.put(domainName, fakeResult); + } + + public static void resetFakeResults() { + fakeResults.clear(); + } + + public enum Status { + SUCCESS, + NAME_NOT_FOUND, + INVALID_NAME, + SERVICE_UNAVAILABLE, + UNKNOWN_FAILURE + } + + public record Result(Status status, List records, NamingException exception) { + + + public static Result fromRecords(final NamingEnumeration recordEnumeration) { + final List records = recordEnumeration == null + ? emptyList() + : EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList(); + return new Result(Status.SUCCESS, records, null); + } + + public static Result fromRecords(final String... records) { + return new Result(Status.SUCCESS, stream(records).toList(), null); + } + + public static Result fromException(final NamingException exception) { + return switch (exception) { + case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc); + case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc); + case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc); + case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc); + }; + } + } + + private final String domainName; + + public Dns(final String domainName) { + this.domainName = domainName; + } + + public Result fetchRecordsOfType(final String recordType) { + if (fakeResults.containsKey(domainName)) { + return fakeResults.get(domainName); + } + + try { + final var env = new Hashtable<>(); + env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory"); + final Attribute records = new InitialDirContext(env) + .getAttributes(domainName, new String[] { recordType }) + .get(recordType); + return Result.fromRecords(records != null ? records.getAll() : null); + } catch (final NamingException exception) { + return Result.fromException(exception); + } + } + + public static void main(String[] args) { + final var result = new Dns("example.org").fetchRecordsOfType("TXT"); + System.out.println(result); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java new file mode 100644 index 00000000..3e5850e5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java @@ -0,0 +1,131 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.errors.MultiValidationException; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; + +import jakarta.persistence.EntityManager; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Wraps the steps of the pararation, validation, mapping and revamp around saving of a HsHostingAsset into a readable API. + */ +public class HostingAssetEntitySaveProcessor { + + private final HsEntityValidator validator; + private String expectedStep = "preprocessEntity"; + private final EntityManager em; + private HsHostingAsset entity; + private HsHostingAssetResource resource; + + public HostingAssetEntitySaveProcessor(final EntityManager em, final HsHostingAsset entity) { + this.em = em; + this.entity = entity; + this.validator = HostingAssetEntityValidatorRegistry.forType(entity.getType()); + } + + /// initial step allowing to set default values before any validations + public HostingAssetEntitySaveProcessor preprocessEntity() { + step("preprocessEntity", "validateEntity"); + validator.preprocessEntity(entity); + return this; + } + + /// validates the entity itself including its properties + public HostingAssetEntitySaveProcessor validateEntity() { + step("validateEntity", "prepareForSave"); + MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity)); + return this; + } + + // TODO.impl: remove once the migration of legacy data is done + /// validates the entity itself including its properties, but ignoring some error messages for import of legacy data + public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) { + step("validateEntity", "prepareForSave"); + final var ignoreRegExpPatterns = Arrays.stream(ignoreRegExp).map(Pattern::compile).toList(); + MultiValidationException.throwIfNotEmpty( + validator.validateEntity(entity).stream() + .filter(error -> ignoreRegExpPatterns.stream().noneMatch(p -> p.matcher(error).matches() )) + .toList() + ); + return this; + } + + /// hashing passwords etc. + @SuppressWarnings("unchecked") + public HostingAssetEntitySaveProcessor prepareForSave() { + step("prepareForSave", "save"); + validator.prepareProperties(em, entity); + return this; + } + + /** + * Saves the entity using the given `saveFunction`. + * + *

`validator.postPersist(em, entity)` is NOT called. + * If any postprocessing is necessary, the saveFunction has to implement this.

+ * @param saveFunction + * @return + */ + public HostingAssetEntitySaveProcessor saveUsing(final Function saveFunction) { + step("save", "validateContext"); + entity = saveFunction.apply(entity); + return this; + } + + /** + * Saves the using the `EntityManager`, but does NOT ever merge the entity. + * + *

`validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.

+ * @return + */ + public HostingAssetEntitySaveProcessor save() { + return saveUsing(e -> { + if (!em.contains(entity)) { + em.persist(entity); + } + em.flush(); // makes RbacEntity available as RealEntity if needed + validator.postPersist(em, entity); + return entity; + }); + } + + /// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits) + public HostingAssetEntitySaveProcessor validateContext() { + step("validateContext", "mapUsing"); + return HsEntityValidator.doWithEntityManager(em, () -> { + MultiValidationException.throwIfNotEmpty(validator.validateContext(entity)); + return this; + }); + } + + /// maps entity to JSON resource representation + public HostingAssetEntitySaveProcessor mapUsing( + final Function mapFunction) { + step("mapUsing", "revampProperties"); + resource = mapFunction.apply(entity); + return this; + } + + /// removes write-only-properties and ads computed-properties + @SuppressWarnings("unchecked") + public HsHostingAssetResource revampProperties() { + step("revampProperties", null); + final var revampedProps = validator.revampProperties(em, entity, (Map) resource.getConfig()); + resource.setConfig(revampedProps); + return resource; + } + + // Makes sure that the steps are called in the correct order. + // Could also be implemented using an interface per method, but that seems exaggerated. + private void step(final String current, final String next) { + if (!expectedStep.equals(current)) { + throw new IllegalStateException("expected " + expectedStep + " but got " + current); + } + expectedStep = next; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java new file mode 100644 index 00000000..bff087f4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java @@ -0,0 +1,238 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.ValidatableProperty; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; + +public abstract class HostingAssetEntityValidator extends HsEntityValidator { + + static final ValidatableProperty[] NO_EXTRA_PROPERTIES = new ValidatableProperty[0]; + + private final ReferenceValidator bookingItemReferenceValidation; + private final ReferenceValidator parentAssetReferenceValidation; + private final ReferenceValidator assignedToAssetReferenceValidation; + private final HostingAssetEntityValidator.AlarmContact alarmContactValidation; + + HostingAssetEntityValidator( + final HsHostingAssetType assetType, + final AlarmContact alarmContactValidation, // hostmaster alert address is implicitly added where needed + final ValidatableProperty... properties) { + super(properties); + this.bookingItemReferenceValidation = new ReferenceValidator<>( + assetType.bookingItemPolicy(), + assetType.bookingItemTypes(), + HsHostingAsset::getBookingItem, + HsBookingItem::getType); + this.parentAssetReferenceValidation = new ReferenceValidator<>( + assetType.parentAssetPolicy(), + assetType.parentAssetTypes(), + HsHostingAsset::getParentAsset, + HsHostingAsset::getType); + this.assignedToAssetReferenceValidation = new ReferenceValidator<>( + assetType.assignedToAssetPolicy(), + assetType.assignedToAssetTypes(), + HsHostingAsset::getAssignedToAsset, + HsHostingAsset::getType); + this.alarmContactValidation = alarmContactValidation; + } + + @Override + public List validateEntity(final HsHostingAsset assetEntity) { + return sequentiallyValidate( + () -> validateEntityReferencesAndProperties(assetEntity), + () -> validateIdentifierPattern(assetEntity) + ); + } + + @Override + public List validateContext(final HsHostingAsset assetEntity) { + return sequentiallyValidate( + () -> optionallyValidate(assetEntity.getBookingItem()), + () -> optionallyValidate(assetEntity.getParentAsset()), + () -> validateAgainstSubEntities(assetEntity) + ); + } + + private List validateEntityReferencesAndProperties(final HsHostingAsset assetEntity) { + return Stream.of( + validateReferencedEntity(assetEntity, "bookingItem", bookingItemReferenceValidation::validate), + validateReferencedEntity(assetEntity, "parentAsset", parentAssetReferenceValidation::validate), + validateReferencedEntity(assetEntity, "assignedToAsset", assignedToAssetReferenceValidation::validate), + validateReferencedEntity(assetEntity, "alarmContact", alarmContactValidation::validate), + validateProperties(assetEntity)) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .toList(); + } + + private List validateReferencedEntity( + final HsHostingAsset assetEntity, + final String referenceFieldName, + final BiFunction> validator) { + return enrich(prefix(assetEntity.toShortString()), validator.apply(assetEntity, referenceFieldName)); + } + + private List validateProperties(final HsHostingAsset assetEntity) { + return enrich(prefix(assetEntity.toShortString(), "config"), super.validateProperties(assetEntity)); + } + + private static List optionallyValidate(final HsHostingAsset assetEntity) { + return assetEntity != null + ? enrich( + prefix(assetEntity.toShortString(), "parentAsset"), + HostingAssetEntityValidatorRegistry.forType(assetEntity.getType()).validateContext(assetEntity)) + : emptyList(); + } + + private static List optionallyValidate(final HsBookingItem bookingItem) { + return bookingItem != null + ? enrich( + prefix(bookingItem.toShortString(), "bookingItem"), + HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + : emptyList(); + } + + protected List validateAgainstSubEntities(final HsHostingAsset assetEntity) { + return enrich( + prefix(assetEntity.toShortString(), "config"), + stream(propertyValidators) + .filter(ValidatableProperty::isTotalsValidator) + .map(prop -> validateMaxTotalValue(assetEntity, prop)) + .filter(Objects::nonNull) + .toList()); + } + + // TODO.test: check, if there are any hosting assets which need this validation at all + private String validateMaxTotalValue( + final HsHostingAsset hostingAsset, + final ValidatableProperty propDef) { + final var propName = propDef.propertyName(); + final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse(""); + final var totalValue = ofNullable(hostingAsset.getSubHostingAssets()).orElse(emptyList()) + .stream() + .map(subItem -> propDef.getValue(subItem.getConfig())) + .map(HsEntityValidator::toIntegerWithDefault0) + .reduce(0, Integer::sum); + final var maxValue = getIntegerValueWithDefault0(propDef, hostingAsset.getConfig()); + return totalValue > maxValue + ? "%s' maximum total is %d%s, but actual total %s is %d%s".formatted( + propName, maxValue, propUnit, propName, totalValue, propUnit) + : null; + } + + private List validateIdentifierPattern(final HsHostingAsset assetEntity) { + final var expectedIdentifierPattern = identifierPattern(assetEntity); + if (assetEntity.getIdentifier() == null || + !expectedIdentifierPattern.matcher(assetEntity.getIdentifier()).matches()) { + return List.of( + "'identifier' expected to match '" + expectedIdentifierPattern + "', but is '" + assetEntity.getIdentifier() + + "'"); + } + return Collections.emptyList(); + } + + protected abstract Pattern identifierPattern(HsHostingAsset assetEntity); + + static class ReferenceValidator { + + private final HsHostingAssetType.RelationPolicy policy; + private final Set referencedEntityTypes; + private final Function referencedEntityGetter; + private final Function referencedEntityTypeGetter; + + public ReferenceValidator( + final HsHostingAssetType.RelationPolicy policy, + final Set referencedEntityTypes, + final Function referencedEntityGetter, + final Function referencedEntityTypeGetter) { + this.policy = policy; + this.referencedEntityTypes = referencedEntityTypes; + this.referencedEntityGetter = referencedEntityGetter; + this.referencedEntityTypeGetter = referencedEntityTypeGetter; + } + + public ReferenceValidator( + final HsHostingAssetType.RelationPolicy policy, + final Function referencedEntityGetter) { + this.policy = policy; + this.referencedEntityTypes = Set.of(); + this.referencedEntityGetter = referencedEntityGetter; + this.referencedEntityTypeGetter = e -> null; + } + + List validate(final HsHostingAsset assetEntity, final String referenceFieldName) { + + final var referencedEntity = referencedEntityGetter.apply(assetEntity); + final var referencedEntityType = referencedEntity != null ? referencedEntityTypeGetter.apply(referencedEntity) : null; + + switch (policy) { + case REQUIRED: + if (!referencedEntityTypes.contains(referencedEntityType)) { + return List.of(referencedEntityType == null + ? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null" + : referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType); + } + break; + case TERMINATORY: + if (assetEntity.getParentAsset() != null && assetEntity.getBookingItem() != null) { + return List.of(referenceFieldName + "' or parentItem must be null but is of type " + referencedEntityType); + } + if (assetEntity.getParentAsset() == null && !referencedEntityTypes.contains(referencedEntityType)) { + return List.of(referencedEntityType == null + ? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null" + : referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType); + } + break; + case OPTIONAL: + if (referencedEntityType != null && !referencedEntityTypes.contains(referencedEntityType)) { + return List.of(referenceFieldName + "' must be null or of type " + toDisplay(referencedEntityTypes) + " but is of type " + + referencedEntityType); + } + break; + case FORBIDDEN: + if (referencedEntityType != null) { + return List.of(referenceFieldName + "' must be null but is of type " + referencedEntityType); + } + break; + } + return emptyList(); + } + + private String toDisplay(final Set referencedEntityTypes) { + return referencedEntityTypes.stream().sorted().map(Object::toString).collect(Collectors.joining(" or ")); + } + } + + static class AlarmContact extends ReferenceValidator> { + + AlarmContact(final HsHostingAssetType.RelationPolicy policy) { + super(policy, HsHostingAsset::getAlarmContact); + } + + // hostmaster alert address is implicitly added where neccessary + static AlarmContact isOptional() { + return new AlarmContact(HsHostingAssetType.RelationPolicy.OPTIONAL); + } + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java new file mode 100644 index 00000000..5f7a453c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java @@ -0,0 +1,56 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; + +import java.util.*; + +import static java.util.Arrays.stream; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.*; + +public class HostingAssetEntityValidatorRegistry { + + private static final Map, HsEntityValidator> validators = new HashMap<>(); + static { + // HOWTO: add (register) new HsHostingAssetType-specific validators + register(CLOUD_SERVER, new HsCloudServerHostingAssetValidator()); + register(MANAGED_SERVER, new HsManagedServerHostingAssetValidator()); + register(MANAGED_WEBSPACE, new HsManagedWebspaceHostingAssetValidator()); + register(UNIX_USER, new HsUnixUserHostingAssetValidator()); + register(EMAIL_ALIAS, new HsEMailAliasHostingAssetValidator()); + register(DOMAIN_SETUP, new HsDomainSetupHostingAssetValidator()); + register(DOMAIN_DNS_SETUP, new HsDomainDnsSetupHostingAssetValidator()); + register(DOMAIN_HTTP_SETUP, new HsDomainHttpSetupHostingAssetValidator()); + register(DOMAIN_SMTP_SETUP, new HsDomainSmtpSetupHostingAssetValidator()); + register(DOMAIN_MBOX_SETUP, new HsDomainMboxSetupHostingAssetValidator()); + register(EMAIL_ADDRESS, new HsEMailAddressHostingAssetValidator()); + register(MARIADB_INSTANCE, new HsMariaDbInstanceHostingAssetValidator()); + register(MARIADB_USER, new HsMariaDbUserHostingAssetValidator()); + register(MARIADB_DATABASE, new HsMariaDbDatabaseHostingAssetValidator()); + register(PGSQL_INSTANCE, new HsPostgreSqlDbInstanceHostingAssetValidator()); + register(PGSQL_USER, new HsPostgreSqlUserHostingAssetValidator()); + register(PGSQL_DATABASE, new HsPostgreSqlDatabaseHostingAssetValidator()); + register(IPV4_NUMBER, new HsIPv4NumberHostingAssetValidator()); + register(IPV6_NUMBER, new HsIPv6NumberHostingAssetValidator()); + } + + private static void register(final Enum type, final HsEntityValidator validator) { + stream(validator.propertyValidators).forEach( entry -> { + entry.verifyConsistency(Map.entry(type, validator)); + }); + validators.put(type, validator); + } + + public static HsEntityValidator forType(final Enum type) { + if ( validators.containsKey(type)) { + return validators.get(type); + } + throw new IllegalArgumentException("no validator found for type " + type); + } + + public static Set> types() { + return validators.keySet(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java new file mode 100644 index 00000000..b9719a54 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; + +class HsCloudServerHostingAssetValidator extends HostingAssetEntityValidator { + + HsCloudServerHostingAssetValidator() { + super( + CLOUD_SERVER, + AlarmContact.isOptional(), + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java new file mode 100644 index 00000000..57ffc279 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java @@ -0,0 +1,185 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.system.SystemProcess; + +import java.util.List; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +// TODO.impl: make package private once we've migrated the legacy data +public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator { + + // according to RFC 1035 (section 5) and RFC 1034 + static final String RR_REGEX_NAME = "(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+"; + static final String RR_REGEX_TTL = "(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?"; + static final String RR_REGEX_IN = "[iI][nN][ \t]+"; // record class IN for Internet + static final String RR_RECORD_TYPE = "[a-zA-Z]+[ \t]+"; + static final String RR_RECORD_DATA = "(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*"; + static final String RR_COMMENT = "(;.*)?"; + + static final String RR_REGEX_TTL_IN = + RR_REGEX_NAME + RR_REGEX_TTL + RR_REGEX_IN + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT; + + static final String RR_REGEX_IN_TTL = + RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT; + public static final String IDENTIFIER_SUFFIX = "|DNS"; + + private static List zoneFileErrors = null; // TODO.impl: remove once legacy data is migrated + + HsDomainDnsSetupHostingAssetValidator() { + super( + DOMAIN_DNS_SETUP, + AlarmContact.isOptional(), + + integerProperty("TTL").min(0).withDefault(21600), + booleanProperty("auto-SOA").withDefault(true), + booleanProperty("auto-NS-RR").withDefault(true), + booleanProperty("auto-MX-RR").withDefault(true), + booleanProperty("auto-A-RR").withDefault(true), + booleanProperty("auto-AAAA-RR").withDefault(true), + booleanProperty("auto-MAILSERVICES-RR").withDefault(true), + booleanProperty("auto-AUTOCONFIG-RR").withDefault(true), + booleanProperty("auto-AUTODISCOVER-RR").withDefault(true), + booleanProperty("auto-DKIM-RR").withDefault(true), + booleanProperty("auto-SPF-RR").withDefault(true), + booleanProperty("auto-WILDCARD-MX-RR").withDefault(true), + booleanProperty("auto-WILDCARD-A-RR").withDefault(true), + booleanProperty("auto-WILDCARD-AAAA-RR").withDefault(true), + booleanProperty("auto-WILDCARD-SPF-RR").withDefault(true), + arrayOf( + stringProperty("user-RR").matchesRegEx(RR_REGEX_TTL_IN, RR_REGEX_IN_TTL).required() + ).optional()); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } + + @Override + @SneakyThrows + public List validateContext(final HsHostingAsset assetEntity) { + final var result = super.validateContext(assetEntity); + + // TODO.spec: define which checks should get raised to error level + final var namedCheckZone = new SystemProcess("named-checkzone", fqdn(assetEntity)); + final var zonefileString = toZonefileString(assetEntity); + final var zoneFileErrorResult = zoneFileErrors != null ? zoneFileErrors : result; + if (namedCheckZone.execute(zonefileString) != 0) { + // yes, named-checkzone writes error messages to stdout, not stderr + stream(namedCheckZone.getStdOut().split("\n")) + .map(line -> line.replaceAll(" stream-0x[0-9a-f]+:", "line ")) + .map(line -> "[" + assetEntity.getIdentifier() + "] " + line) + .forEach(zoneFileErrorResult::add); + if (!namedCheckZone.getStdErr().isEmpty()) { + result.add("unexpected stderr output for " + namedCheckZone.getCommand() + ": " + namedCheckZone.getStdErr()); + } + } + return result; + } + + String toZonefileString(final HsHostingAsset assetEntity) { + // TODO.spec: we need to expand the templates (auto-...) in the same way as in Saltstack, with proper IP-numbers etc. + // TODO.impl: auto-AUTOCONFIG-RR auto-AUTODISCOVER-RR missing + return """ + $TTL {ttl} + + {auto-SOA} + {auto-NS-RR} + {auto-MX-RR} + {auto-A-RR} + {auto-AAAA-RR} + {auto-DKIM-RR} + {auto-SPF-RR} + + {auto-WILDCARD-MX-RR} + {auto-WILDCARD-A-RR} + {auto-WILDCARD-AAAA-RR} + {auto-WILDCARD-SPF-RR} + + {userRRs} + """ + .replace("{ttl}", assetEntity.getDirectValue("TTL", Integer.class, 43200).toString()) + .replace("{auto-SOA}", assetEntity.getDirectValue("auto-SOA", Boolean.class, false).equals(true) + ? """ + {domain}. IN SOA h00.hostsharing.net. hostmaster.hostsharing.net. ( + 1303649373 ; serial secs since Jan 1 1970 + 6H ; refresh (>=10000) + 1H ; retry (>=1800) + 1W ; expire + 1H ; minimum + ) + """ + : "; no auto-SOA" + ) + .replace("{auto-NS-RR}", assetEntity.getDirectValue("auto-NS-RR", Boolean.class, true) + ? """ + {domain}. IN NS dns1.hostsharing.net. + {domain}. IN NS dns2.hostsharing.net. + {domain}. IN NS dns3.hostsharing.net. + """ + : "; no auto-NS-RR") + .replace("{auto-MX-RR}", assetEntity.getDirectValue("auto-MX-RR", Boolean.class, true) + ? """ + {domain}. IN MX 30 mailin1.hostsharing.net. + {domain}. IN MX 30 mailin2.hostsharing.net. + {domain}. IN MX 30 mailin3.hostsharing.net. + """ + : "; no auto-MX-RR") + .replace("{auto-A-RR}", assetEntity.getDirectValue("auto-A-RR", Boolean.class, true) + ? "{domain}. IN A 83.223.95.160" // arbitrary IP-number + : "; no auto-A-RR") + .replace("{auto-AAAA-RR}", assetEntity.getDirectValue("auto-AAA-RR", Boolean.class, true) + ? "{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number + : "; no auto-AAAA-RR") + .replace("{auto-DKIM-RR}", assetEntity.getDirectValue("auto-DKIM-RR", Boolean.class, true) + ? "default._domainkey 21600 IN TXT \"v=DKIM1; h=sha256; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmdM9d15bqe94zbHVcKKpUF875XoCWHKRap/sG3NJZ9xZ/BjfGXmqoEYeFNpX3CB7pOXhH5naq4N+6gTjArTviAiVThHXyebhrxaf1dVS4IUC6raTEyQrWPZUf7ZxXmcCYvOdV4jIQ8GRfxwxqibIJcmMiufXTLIgRUif5uaTgFwIDAQAB\"" + : "; no auto-DKIM-RR") + .replace("{auto-SPF-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true) + ? "{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\"" + : "; no auto-SPF-RR") + .replace("{auto-WILDCARD-MX-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true) + ? """ + *.{domain}. IN MX 30 mailin1.hostsharing.net. + *.{domain}. IN MX 30 mailin1.hostsharing.net. + *.{domain}. IN MX 30 mailin1.hostsharing.net. + """ + : "; no auto-WILDCARD-MX-RR") + .replace("{auto-WILDCARD-A-RR}", assetEntity.getDirectValue("auto-WILDCARD-A-RR", Boolean.class, true) + ? "*.{domain}. IN A 83.223.95.160" // arbitrary IP-number + : "; no auto-WILDCARD-A-RR") + .replace("{auto-WILDCARD-AAAA-RR}", assetEntity.getDirectValue("auto-WILDCARD-AAAA-RR", Boolean.class, true) + ? "*.{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number + : "; no auto-WILDCARD-AAAA-RR") + .replace("{auto-WILDCARD-SPF-RR}", assetEntity.getDirectValue("auto-WILDCARD-SPF-RR", Boolean.class, true) + ? "*.{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\"" + : "; no auto-WILDCARD-SPF-RR") + .replace("{domain}", fqdn(assetEntity)) + .replace("{userRRs}", getPropertyValues(assetEntity, "user-RR")); + } + + private String fqdn(final HsHostingAsset assetEntity) { + return assetEntity.getIdentifier().substring(0, assetEntity.getIdentifier().length() - IDENTIFIER_SUFFIX.length()); + } + + public static void addZonefileErrorsTo(final List zoneFileErrors) { + HsDomainDnsSetupHostingAssetValidator.zoneFileErrors = zoneFileErrors; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java new file mode 100644 index 00000000..f98daea7 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java @@ -0,0 +1,56 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsDomainHttpSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|HTTP"; + public static final String FILESYSTEM_PATH = "^/.*"; + public static final String SUBDOMAIN_NAME_REGEX = "(\\*|(?!-)[A-Za-z0-9-]{1,63}(? entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java new file mode 100644 index 00000000..41c1aa52 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; + +class HsDomainMboxSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|MBOX"; + + HsDomainMboxSetupHostingAssetValidator() { + super( + DOMAIN_MBOX_SETUP, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java new file mode 100644 index 00000000..a4ad06a4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java @@ -0,0 +1,128 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX; + +class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName"; + + HsDomainSetupHostingAssetValidator() { + super( + DOMAIN_SETUP, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES); + } + + @Override + public List validateEntity(final HsHostingAsset assetEntity) { + final var violations = super.validateEntity(assetEntity); + if (!violations.isEmpty() || assetEntity.isLoaded()) { + // it makes no sense to do DNS-based validation + // if the entity is already persisted or + // if the identifier (domain name) or structure is already invalid + return violations; + } + + final var dnsResult = new Dns(assetEntity.getIdentifier()).fetchRecordsOfType("TXT"); + switch (dnsResult.status()) { + case Dns.Status.SUCCESS: + violations.addAll(handleDomainNameFound(assetEntity, dnsResult)); + break; + + case Dns.Status.NAME_NOT_FOUND: + violations.addAll(handleDomainNameNotFoundError(assetEntity, dnsResult)); + break; + + case Dns.Status.INVALID_NAME: + // should not happen because we validate the domain name at booking item level + violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'"); + break; + + case Dns.Status.SERVICE_UNAVAILABLE: + case Dns.Status.UNKNOWN_FAILURE: + violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception()); + break; + } + return violations; + } + + private static String verificationCode(final HsHostingAsset assetEntity) { + return assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + if (assetEntity.getBookingItem() != null) { + final var bookingItemDomainName = assetEntity.getBookingItem() + .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); + return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL); + } + final var parentDomainName = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE); + } + + private static List handleDomainNameFound(final HsHostingAsset assetEntity, final Dns.Result dnsResult) { + final var violations = new ArrayList(); + final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity); + final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue) + .or(() -> superDomain(assetEntity.getIdentifier()) + .flatMap(superDomainName -> findTxtRecord( + new Dns(superDomainName).fetchRecordsOfType("TXT"), + expectedTxtRecordValue)) + ); + if (verificationFound.isEmpty()) { + violations.add( + "[DNS] no TXT record '" + expectedTxtRecordValue + + "' found for domain name '" + assetEntity.getIdentifier() + "' (nor in its super-domain)"); + } + return violations; + } + + private static List handleDomainNameNotFoundError(final HsHostingAsset assetEntity, final Dns.Result dnsResult) { + final var violations = new ArrayList(); + if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) { + final var superDomain = superDomain(assetEntity.getIdentifier()); + final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity); + final var verificationFoundInSuperDomain = superDomain.map(superDomainName -> + { + final Dns.Result superDomainDnsResult = new Dns(superDomainName).fetchRecordsOfType("TXT"); + if (superDomainDnsResult.status() != Dns.Status.SUCCESS) { + violations.add("[DNS] lookup failed for domain name '" + superDomainName + "': " + dnsResult.exception()); + } + return superDomainDnsResult; + } + ) + .flatMap(records -> findTxtRecord(records, expectedTxtRecordValue)); + if (verificationFoundInSuperDomain.isEmpty()) { + violations.add( + "[DNS] no TXT record '" + expectedTxtRecordValue + + "' found for domain name '" + superDomain.orElseThrow() + "'"); + } + } else { + // otherwise no DNS verification to be able to setup DNS for domains to register + } + return violations; + } + + private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) { + return !Dns.isRegistrableDomain(assetEntity.getIdentifier()) + && assetEntity.getParentAsset() == null; + } + + + private static Optional findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) { + return result.records().stream() + .filter(r -> r.contains(expectedTxtRecordValue)) + .findAny(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java new file mode 100644 index 00000000..bc422029 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP; + +class HsDomainSmtpSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|SMTP"; + + HsDomainSmtpSetupHostingAssetValidator() { + super( + DOMAIN_SMTP_SETUP, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java new file mode 100644 index 00000000..77c32768 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java @@ -0,0 +1,53 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsEMailAddressHostingAssetValidator extends HostingAssetEntityValidator { + + private static final String TARGET_MAILBOX_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$"; // also accepts legacy pac-names + private static final String EMAIL_ADDRESS_LOCAL_PART_REGEX = "[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+"; // RFC 5322 + private static final String EMAIL_ADDRESS_DOMAIN_PART_REGEX = "[a-zA-Z0-9.-]+"; + private static final String EMAIL_ADDRESS_FULL_REGEX = "^(" + EMAIL_ADDRESS_LOCAL_PART_REGEX + ")?@" + EMAIL_ADDRESS_DOMAIN_PART_REGEX + "$"; + private static final String NOBODY_REGEX = "^nobody$"; + private static final String DEVNULL_REGEX = "^/dev/null$"; + public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322 + + HsEMailAddressHostingAssetValidator() { + super( HsHostingAssetType.EMAIL_ADDRESS, + AlarmContact.isOptional(), + + stringProperty("local-part").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(), + stringProperty("sub-domain").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(), + arrayOf( + stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(TARGET_MAILBOX_REGEX, EMAIL_ADDRESS_FULL_REGEX, NOBODY_REGEX, DEVNULL_REGEX) + ).required().minLength(1)); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + entity.setIdentifier(combineIdentifier(entity)); + } + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^"+ Pattern.quote(combineIdentifier(assetEntity)) + "$"); + } + + private static String combineIdentifier(final HsHostingAsset emailAddressAssetEntity) { + return ofNullable(emailAddressAssetEntity.getDirectValue("local-part", String.class)).orElse("") + + "@" + + ofNullable(emailAddressAssetEntity.getDirectValue("sub-domain", String.class)).map(s -> s + ".").orElse("") + + emailAddressAssetEntity.getParentAsset().getParentAsset().getIdentifier(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java new file mode 100644 index 00000000..f6c412bb --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsEMailAliasHostingAssetValidator extends HostingAssetEntityValidator { + + private static final String UNIX_USER_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$"; // also accepts legacy pac-names + private static final String EMAIL_ADDRESS_REGEX = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$"; // RFC 5322 + private static final String INCLUDE_REGEX = "^:include:/.*$"; + private static final String PIPE_REGEX = "^\\|.*$"; + private static final String DEV_NULL_REGEX = "^/dev/null$"; + public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322 + + HsEMailAliasHostingAssetValidator() { + super( HsHostingAssetType.EMAIL_ALIAS, + AlarmContact.isOptional(), + + arrayOf( + stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(UNIX_USER_REGEX, EMAIL_ADDRESS_REGEX, INCLUDE_REGEX, PIPE_REGEX, DEV_NULL_REGEX) + ).required().minLength(1)); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9][a-z0-9\\._-]*$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java new file mode 100644 index 00000000..b237729e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java @@ -0,0 +1,26 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV4_NUMBER; + +class HsIPv4NumberHostingAssetValidator extends HostingAssetEntityValidator { + + private static final Pattern IPV4_REGEX = Pattern.compile("^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$"); + + HsIPv4NumberHostingAssetValidator() { + super( + IPV4_NUMBER, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES + ); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return IPV4_REGEX; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java new file mode 100644 index 00000000..873a73eb --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java @@ -0,0 +1,49 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV6_NUMBER; + +class HsIPv6NumberHostingAssetValidator extends HostingAssetEntityValidator { + + // simplified pattern, the real check is done by letting Java parse the address + private static final Pattern IPV6_REGEX = Pattern.compile("([a-f0-9:]+:+)+[a-f0-9]+"); + + HsIPv6NumberHostingAssetValidator() { + super( + IPV6_NUMBER, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES + ); + } + + @Override + public List validateEntity(final HsHostingAsset assetEntity) { + final var violations = super.validateEntity(assetEntity); + + if (!isValidIPv6Address(assetEntity.getIdentifier())) { + violations.add("'identifier' expected to be a valid IPv6 address, but is '" + assetEntity.getIdentifier() + "'"); + } + + return violations; + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return IPV6_REGEX; + } + + private boolean isValidIPv6Address(final String identifier) { + try { + return InetAddress.getByName(identifier) instanceof java.net.Inet6Address; + } catch (UnknownHostException e) { + return false; + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java new file mode 100644 index 00000000..99138e0e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java @@ -0,0 +1,60 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedServerHostingAssetValidator extends HostingAssetEntityValidator { + + public HsManagedServerHostingAssetValidator() { + super( + MANAGED_SERVER, + AlarmContact.isOptional(), + + // monitoring + integerProperty("monit_max_cpu_usage").unit("%").min(10).max(100).withDefault(92), + integerProperty("monit_max_ram_usage").unit("%").min(10).max(100).withDefault(92), + integerProperty("monit_max_ssd_usage").unit("%").min(10).max(100).withDefault(98), + integerProperty("monit_min_free_ssd").min(1).max(1000).withDefault(5), + integerProperty("monit_max_hdd_usage").unit("%").min(10).max(100).withDefault(95), + integerProperty("monit_min_free_hdd").min(1).max(4000).withDefault(10), + + // other settings + // booleanProperty("fastcgi_small").withDefault(false), TODO.spec: clarify Salt-Grains + + // database software + booleanProperty("software-pgsql").withDefault(true), + booleanProperty("software-mariadb").withDefault(true), + + // PHP + enumerationProperty("php-default").valuesFromProperties("software-php-").withDefault("8.2"), + booleanProperty("software-php-5.6").withDefault(false), + booleanProperty("software-php-7.0").withDefault(false), + booleanProperty("software-php-7.1").withDefault(false), + booleanProperty("software-php-7.2").withDefault(false), + booleanProperty("software-php-7.3").withDefault(false), + booleanProperty("software-php-7.4").withDefault(true), + booleanProperty("software-php-8.0").withDefault(false), + booleanProperty("software-php-8.1").withDefault(false), + booleanProperty("software-php-8.2").withDefault(true), + + // other software + booleanProperty("software-postfix-tls-1.0").withDefault(false), + booleanProperty("software-dovecot-tls-1.0").withDefault(false), + booleanProperty("software-clamav").withDefault(true), + booleanProperty("software-collabora").withDefault(false), + booleanProperty("software-libreoffice").withDefault(false), + booleanProperty("software-imagemagick-ghostscript").withDefault(false) + ); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java new file mode 100644 index 00000000..4579faf8 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java @@ -0,0 +1,50 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; + +import jakarta.persistence.EntityManager; +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedWebspaceHostingAssetValidator extends HostingAssetEntityValidator { + public HsManagedWebspaceHostingAssetValidator() { + super( + MANAGED_WEBSPACE, + AlarmContact.isOptional(), + integerProperty("groupid").readOnly() + ); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var prefixPattern = + !assetEntity.isLoaded() + ? assetEntity.getRelatedProject().getDebitor().getDefaultPrefix() + : "[a-z][a-z0-9][a-z0-9]"; + return Pattern.compile("^" + prefixPattern + "[0-9][0-9]$"); + } + + @Override + public void postPersist(final EntityManager em, final HsHostingAsset webspaceAsset) { + if (!webspaceAsset.isLoaded()) { + final var unixUserAsset = HsHostingAssetRealEntity.builder() + .type(UNIX_USER) + .parentAsset(em.find(HsHostingAssetRealEntity.class, webspaceAsset.getUuid())) + .identifier(webspaceAsset.getIdentifier()) + .caption(webspaceAsset.getIdentifier() + " webspace user") + .build(); + webspaceAsset.getSubHostingAssets().add(unixUserAsset); + new HostingAssetEntitySaveProcessor(em, unixUserAsset) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .save() + .validateContext(); + webspaceAsset.getConfig().put("groupid", unixUserAsset.getConfig().get("userid")); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java new file mode 100644 index 00000000..823308ed --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsMariaDbDatabaseHostingAssetValidator extends HostingAssetEntityValidator { + + final static String HEAD_REGEXP = "^MAD\\|"; + + public HsMariaDbDatabaseHostingAssetValidator() { + super( + MARIADB_DATABASE, + AlarmContact.isOptional(), + + stringProperty("encoding").matchesRegEx("[a-z0-9_]+").maxLength(24).provided("latin1", "utf8").withDefault("utf8")); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getParentAsset().getIdentifier(); + return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java new file mode 100644 index 00000000..d9509906 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java @@ -0,0 +1,37 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE; + +class HsMariaDbInstanceHostingAssetValidator extends HostingAssetEntityValidator { + + final static String DEFAULT_INSTANCE_IDENTIFIER_SUFFIX = "|MariaDB.default"; // TODO.spec: specify instance naming + + public HsMariaDbInstanceHostingAssetValidator() { + super( + MARIADB_INSTANCE, + AlarmContact.isOptional(), + NO_EXTRA_PROPERTIES); // TODO.spec: specify instance properties, e.g. installed extensions + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile( + "^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX) + + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier( + pa.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java new file mode 100644 index 00000000..58a33520 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java @@ -0,0 +1,35 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; + +class HsMariaDbUserHostingAssetValidator extends HostingAssetEntityValidator { + + final static String HEAD_REGEXP = "^MAU\\|"; + + public HsMariaDbUserHostingAssetValidator() { + super( + MARIADB_USER, + AlarmContact.isOptional(), + + // TODO.impl: we need to be able to suppress updating of fields etc., something like this: + // withFieldValidation( + // referenceProperty(alarmContact).isOptional(), + // referenceProperty(parentAsset).isWriteOnce(), + // referenceProperty(assignedToAsset).isWriteOnce(), + // ); + + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.MYSQL_NATIVE).writeOnly()); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java new file mode 100644 index 00000000..830b2fbf --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java @@ -0,0 +1,30 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsPostgreSqlDatabaseHostingAssetValidator extends HostingAssetEntityValidator { + + final static String HEAD_REGEXP = "^PGD\\|"; + + public HsPostgreSqlDatabaseHostingAssetValidator() { + super( + PGSQL_DATABASE, + AlarmContact.isOptional(), + + stringProperty("encoding").matchesRegEx("[A-Z0-9_]+").maxLength(24).provided("LATIN1", "UTF8").withDefault("UTF8") + + // TODO.spec: PostgreSQL extensions in instance and here? also decide which. Free selection or booleans/checkboxes? + ); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getParentAsset().getIdentifier(); + return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java new file mode 100644 index 00000000..70de55f9 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java @@ -0,0 +1,39 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_INSTANCE; + +class HsPostgreSqlDbInstanceHostingAssetValidator extends HostingAssetEntityValidator { + + final static String DEFAULT_INSTANCE_IDENTIFIER_SUFFIX = "|PgSql.default"; // TODO.spec: specify instance naming + + public HsPostgreSqlDbInstanceHostingAssetValidator() { + super( + PGSQL_INSTANCE, + AlarmContact.isOptional(), + + // TODO.spec: PostgreSQL extensions in database and here? also decide which. Free selection or booleans/checkboxes? + NO_EXTRA_PROPERTIES); // TODO.spec: specify instance properties, e.g. installed extensions + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + return Pattern.compile( + "^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX) + + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAsset entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier( + pa.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java new file mode 100644 index 00000000..e10b6e6c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java @@ -0,0 +1,35 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; + +class HsPostgreSqlUserHostingAssetValidator extends HostingAssetEntityValidator { + + final static String HEAD_REGEXP = "^PGU\\|"; + + public HsPostgreSqlUserHostingAssetValidator() { + super( + PGSQL_USER, + AlarmContact.isOptional(), + + // TODO.impl: we need to be able to suppress updating of fields etc., something like this: + // withFieldValidation( + // referenceProperty(alarmContact).isOptional(), + // referenceProperty(parentAsset).isWriteOnce(), + // referenceProperty(assignedToAsset).isWriteOnce(), + // ); + + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.SCRAM_SHA256).writeOnly()); + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java new file mode 100644 index 00000000..a53b536f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java @@ -0,0 +1,60 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; + +import jakarta.persistence.EntityManager; +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsUnixUserHostingAssetValidator extends HostingAssetEntityValidator { + + private static final int DASH_LENGTH = "-".length(); + + HsUnixUserHostingAssetValidator() { + super( + HsHostingAssetType.UNIX_USER, + AlarmContact.isOptional(), + + booleanProperty("locked").readOnly(), + integerProperty("userid").readOnly().initializedBy(HsUnixUserHostingAssetValidator::computeUserId), + + integerProperty("SSD hard quota").unit("MB").maxFrom("SSD").withFactor(1024).optional(), + integerProperty("SSD soft quota").unit("MB").maxFrom("SSD hard quota").optional(), + integerProperty("HDD hard quota").unit("MB").maxFrom("HDD").withFactor(1024).optional(), + integerProperty("HDD soft quota").unit("MB").maxFrom("HDD hard quota").optional(), + stringProperty("shell") + // TODO.spec: do we want to change them all to /usr/bin/, also in import? + .provided("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd") + .withDefault("/bin/false"), + stringProperty("homedir").readOnly().renderedBy(HsUnixUserHostingAssetValidator::computeHomedir), + stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(), + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.LINUX_SHA512).writeOnly()); + // TODO.spec: public SSH keys? (only if hsadmin-ng is only accessible with 2FA) + } + + @Override + protected Pattern identifierPattern(final HsHostingAsset assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9\\._-]+$"); + } + + private static String computeHomedir(final EntityManager em, final PropertiesProvider propertiesProvider) { + final var entity = (HsHostingAsset) propertiesProvider; + final var webspaceName = entity.getParentAsset().getIdentifier(); + return "/home/pacs/" + webspaceName + + "/users/" + entity.getIdentifier().substring(webspaceName.length()+DASH_LENGTH); + } + + private static Integer computeUserId(final EntityManager em, final PropertiesProvider propertiesProvider) { + final Object result = em.createNativeQuery("SELECT nextval('hs_hosting_asset_unixuser_system_id_seq')", Integer.class) + .getSingleResult(); + return (Integer) result; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md new file mode 100644 index 00000000..72470290 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md @@ -0,0 +1,40 @@ +### HsHostingAsset-Validation + +There is just a single `HsHostingAsset` interface and `HsHostingAssetEntity` entity for all types of hosting assets like Managed-Server, Managed-Webspace, Unix-Users, Databases etc. These are distinguished by `HsHostingAssetType HsHostingAsset.type`. + +For each of these types, a distinct validator has to be +implemented as a subclass of `HsHostingAssetValidator` which needs to be registered (see `HsHostingAssetValidatorRegistry`) for the relevant type(s). + +### Kinds of Validations + +#### Identifier validation + +The identifier of a Hosting-Asset is for example the Webspace-Name like "xyz00" or a Unix-User-Name like "xyz00-test". + +To validate the identifier, vverride the method `identifierPattern(...)` and return a regular expression to validate the identifier against. The regular expression can depend on the actual entity instance. + +#### Reference validation + +References in this context are: +- the related Booking-Item, +- the parent-Hosting-Asset, +- the Assigned-To-Hosting-Asset and +- the Contact. + +The first parameters of the `HsHostingAssetValidator` superclass take rule descriptors for these references. These are all Subclasses fo + +### Validation Order + +The validations are called in a sensible order. E.g. if a property value is not numeric, it makes no sense to check the total sum of such values to be within certain numeric values. And if the related booking item is of wrong type, it makes no sense to validate limits against sub-entities. + +Properties are validated all at once, though. Thus, if multiple properties fail validation, all error messages are returned at once. + +In general, the validation es executed in this order: + +1. the entity itself + 1. its references + 2. its properties +2. the limits of the parent entity (parent asset + booking item) +3. limits against the own own-sub-entities + +This implementation can be found in `HsHostingAssetValidator.validate`. diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java index 764d0a4a..9f39767f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java @@ -74,11 +74,11 @@ public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi { public ResponseEntity getBankAccountByUuid( final String currentUser, final String assumedRoles, - final UUID BankAccountUuid) { + final UUID bankAccountUuid) { context.define(currentUser, assumedRoles); - final var result = bankAccountRepo.findByUuid(BankAccountUuid); + final var result = bankAccountRepo.findByUuid(bankAccountUuid); if (result.isEmpty()) { return ResponseEntity.notFound().build(); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java index fd6b0c44..94fe2b16 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java @@ -2,17 +2,20 @@ package net.hostsharing.hsadminng.hs.office.bankaccount; import lombok.*; import lombok.experimental.FieldNameConstants; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; +import java.io.IOException; import java.util.UUID; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -23,18 +26,21 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @NoArgsConstructor @AllArgsConstructor @FieldNameConstants -@DisplayName("BankAccount") -public class HsOfficeBankAccountEntity implements HasUuid, Stringifyable { +@DisplayAs("BankAccount") +public class HsOfficeBankAccountEntity implements BaseEntity, Stringifyable { private static Stringify toString = stringify(HsOfficeBankAccountEntity.class, "bankAccount") + .withIdProp(HsOfficeBankAccountEntity::getIban) .withProp(Fields.holder, HsOfficeBankAccountEntity::getHolder) - .withProp(Fields.iban, HsOfficeBankAccountEntity::getIban) .withProp(Fields.bic, HsOfficeBankAccountEntity::getBic); @Id @GeneratedValue private UUID uuid; + @Version + private int version; + private String holder; private String iban; @@ -50,4 +56,28 @@ public class HsOfficeBankAccountEntity implements HasUuid, Stringifyable { public String toShortString() { return holder; } + + public static RbacView rbac() { + return rbacViewFor("bankAccount", HsOfficeBankAccountEntity.class) + .withIdentityView(SQL.projection("iban")) + .withUpdatableColumns("holder", "iban", "bic") + + .toRole("global", GUEST).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(REFERRER, (with) -> { + with.permission(SELECT); + }); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java index 92b12960..11de3bdb 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java @@ -13,13 +13,15 @@ public interface HsOfficeBankAccountRepository extends Repository findByOptionalHolderLike(String holder); + List findByOptionalHolderLikeImpl(String holder); + default List findByOptionalHolderLike(String holder) { + return findByOptionalHolderLikeImpl(holder == null ? "" : holder); + } - List findByIbanOrderByIban(String iban); + List findByIbanOrderByIbanAsc(String iban); S save(S entity); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java new file mode 100644 index 00000000..9450e331 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java @@ -0,0 +1,106 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.FieldNameConstants; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.Type; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Transient; +import jakarta.persistence.Version; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder(toBuilder = true) +@FieldNameConstants +@DisplayAs("Contact") +public class HsOfficeContact implements Stringifyable, BaseEntity { + + private static Stringify toString = stringify(HsOfficeContact.class, "contact") + .withProp(Fields.caption, HsOfficeContact::getCaption) + .withProp(Fields.emailAddresses, HsOfficeContact::getEmailAddresses); + + @Id + @GeneratedValue(generator = "UUID") + @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") + private UUID uuid; + + @Version + private int version; + + @Column(name = "caption") + private String caption; + + @Column(name = "postaladdress") + private String postalAddress; // multiline free-format text + + @Builder.Default + @Setter(AccessLevel.NONE) + @Type(JsonType.class) + @Column(name = "emailaddresses") + private Map emailAddresses = new HashMap<>(); + + @Transient + private PatchableMapWrapper emailAddressesWrapper; + + @Builder.Default + @Setter(AccessLevel.NONE) + @Type(JsonType.class) + @Column(name = "phonenumbers") + private Map phoneNumbers = new HashMap<>(); + + @Transient + private PatchableMapWrapper phoneNumbersWrapper; + + public PatchableMapWrapper getEmailAddresses() { + return PatchableMapWrapper.of( + emailAddressesWrapper, + (newWrapper) -> {emailAddressesWrapper = newWrapper;}, + emailAddresses); + } + + public void putEmailAddresses(Map newEmailAddresses) { + getEmailAddresses().assign(newEmailAddresses); + } + + public PatchableMapWrapper getPhoneNumbers() { + return PatchableMapWrapper.of(phoneNumbersWrapper, (newWrapper) -> {phoneNumbersWrapper = newWrapper;}, phoneNumbers); + } + + public void putPhoneNumbers(Map newPhoneNumbers) { + getPhoneNumbers().assign(newPhoneNumbers); + } + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { + return caption; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index 073587f2..cee7e28a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -14,6 +14,9 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui import java.util.List; import java.util.UUID; +import java.util.function.BiConsumer; + +import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; @RestController @@ -26,17 +29,17 @@ public class HsOfficeContactController implements HsOfficeContactsApi { private Mapper mapper; @Autowired - private HsOfficeContactRepository contactRepo; + private HsOfficeContactRbacRepository contactRepo; @Override @Transactional(readOnly = true) public ResponseEntity> listContacts( final String currentUser, final String assumedRoles, - final String label) { + final String caption) { context.define(currentUser, assumedRoles); - final var entities = contactRepo.findContactByOptionalLabelLike(label); + final var entities = contactRepo.findContactByOptionalCaptionLike(caption); final var resources = mapper.mapList(entities, HsOfficeContactResource.class); return ResponseEntity.ok(resources); @@ -51,7 +54,7 @@ public class HsOfficeContactController implements HsOfficeContactsApi { context.define(currentUser, assumedRoles); - final var entityToSave = mapper.map(body, HsOfficeContactEntity.class); + final var entityToSave = mapper.map(body, HsOfficeContactRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); final var saved = contactRepo.save(entityToSave); @@ -108,10 +111,16 @@ public class HsOfficeContactController implements HsOfficeContactsApi { final var current = contactRepo.findByUuid(contactUuid).orElseThrow(); - new HsOfficeContactEntityPatch(current).apply(body); + new HsOfficeContactEntityPatcher(current).apply(body); final var saved = contactRepo.save(current); final var mapped = mapper.map(saved, HsOfficeContactResource.class); return ResponseEntity.ok(mapped); } + + @SuppressWarnings("unchecked") + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.putEmailAddresses(from(resource.getEmailAddresses())); + entity.putPhoneNumbers(from(resource.getPhoneNumbers())); + }; } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java deleted file mode 100644 index c3ecb6be..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java +++ /dev/null @@ -1,56 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import lombok.*; -import lombok.experimental.FieldNameConstants; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; -import net.hostsharing.hsadminng.stringify.Stringify; -import net.hostsharing.hsadminng.stringify.Stringifyable; -import org.hibernate.annotations.GenericGenerator; - -import jakarta.persistence.*; -import java.util.UUID; - -import static net.hostsharing.hsadminng.stringify.Stringify.stringify; - -@Entity -@Table(name = "hs_office_contact_rv") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@FieldNameConstants -@DisplayName("Contact") -public class HsOfficeContactEntity implements Stringifyable, HasUuid { - - private static Stringify toString = stringify(HsOfficeContactEntity.class, "contact") - .withProp(Fields.label, HsOfficeContactEntity::getLabel) - .withProp(Fields.emailAddresses, HsOfficeContactEntity::getEmailAddresses); - - - @Id - @GeneratedValue(generator = "UUID") - @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") - private UUID uuid; - private String label; - - @Column(name = "postaladdress") - private String postalAddress; - - @Column(name = "emailaddresses", columnDefinition = "json") - private String emailAddresses; // TODO: check if we can really add multiple. format: ["eins@...", "zwei@..."] - - @Column(name = "phonenumbers", columnDefinition = "json") - private String phoneNumbers; // TODO: check if we can really add multiple. format: { "office": "+49 40 12345-10", "fax": "+49 40 12345-05" } - - @Override - public String toString() { - return toString.apply(this); - } - - @Override - public String toShortString() { - return label; - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java deleted file mode 100644 index af6cfbc6..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java +++ /dev/null @@ -1,22 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import net.hostsharing.hsadminng.mapper.EntityPatcher; -import net.hostsharing.hsadminng.mapper.OptionalFromJson; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource; - -class HsOfficeContactEntityPatch implements EntityPatcher { - - private final HsOfficeContactEntity entity; - - HsOfficeContactEntityPatch(final HsOfficeContactEntity entity) { - this.entity = entity; - } - - @Override - public void apply(final HsOfficeContactPatchResource resource) { - OptionalFromJson.of(resource.getLabel()).ifPresent(entity::setLabel); - OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress); - OptionalFromJson.of(resource.getEmailAddresses()).ifPresent(entity::setEmailAddresses); - OptionalFromJson.of(resource.getPhoneNumbers()).ifPresent(entity::setPhoneNumbers); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java new file mode 100644 index 00000000..e08e6bae --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import net.hostsharing.hsadminng.mapper.EntityPatcher; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.mapper.OptionalFromJson; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource; + +import java.util.Optional; + +class HsOfficeContactEntityPatcher implements EntityPatcher { + + private final HsOfficeContactRbacEntity entity; + + HsOfficeContactEntityPatcher(final HsOfficeContactRbacEntity entity) { + this.entity = entity; + } + + @Override + public void apply(final HsOfficeContactPatchResource resource) { + OptionalFromJson.of(resource.getCaption()).ifPresent(entity::setCaption); + OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress); + Optional.ofNullable(resource.getEmailAddresses()) + .ifPresent(r -> entity.getEmailAddresses().patch(KeyValueMap.from(resource.getEmailAddresses()))); + Optional.ofNullable(resource.getPhoneNumbers()) + .ifPresent(r -> entity.getPhoneNumbers().patch(KeyValueMap.from(resource.getPhoneNumbers()))); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java new file mode 100644 index 00000000..c4e934cc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java @@ -0,0 +1,48 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import lombok.*; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.*; +import java.io.IOException; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "hs_office_contact_rv") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +@DisplayAs("RbacContact") +public class HsOfficeContactRbacEntity extends HsOfficeContact { + + public static RbacView rbac() { + return rbacViewFor("contact", HsOfficeContactRbacEntity.class) + .withIdentityView(SQL.projection("caption")) + .withUpdatableColumns("caption", "postalAddress", "emailAddresses", "phoneNumbers") + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(REFERRER, (with) -> { + with.permission(SELECT); + }) + .toRole(GLOBAL, GUEST).grantPermission(INSERT); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/501-contact/5013-hs-office-contact-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java new file mode 100644 index 00000000..e893bced --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java @@ -0,0 +1,26 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeContactRbacRepository extends Repository { + + Optional findByUuid(UUID id); + + @Query(""" + SELECT c FROM HsOfficeContactRbacEntity c + WHERE :caption is null + OR c.caption like concat(cast(:caption as text), '%') + """) + List findContactByOptionalCaptionLike(String caption); + + HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealEntity.java new file mode 100644 index 00000000..44f72d99 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealEntity.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.errors.DisplayAs; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "hs_office_contact") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder(toBuilder = true) +@DisplayAs("RealContact") +public class HsOfficeContactRealEntity extends HsOfficeContact { + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealRepository.java new file mode 100644 index 00000000..b4099422 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealRepository.java @@ -0,0 +1,26 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeContactRealRepository extends Repository { + + Optional findByUuid(UUID id); + + @Query(""" + SELECT c FROM HsOfficeContactRealEntity c + WHERE :caption is null + OR c.caption like concat(cast(:caption as text), '%') + """) + List findContactByOptionalCaptionLike(String caption); + + HsOfficeContactRealEntity save(final HsOfficeContactRealEntity entity); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepository.java deleted file mode 100644 index 309c3a57..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface HsOfficeContactRepository extends Repository { - - Optional findByUuid(UUID id); - - @Query(""" - SELECT c FROM HsOfficeContactEntity c - WHERE :label is null - OR c.label like concat(cast(:label as text), '%') - """) - List findContactByOptionalLabelLike(String label); - - HsOfficeContactEntity save(final HsOfficeContactEntity entity); - - int deleteByUuid(final UUID uuid); - - long count(); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java index 946b4626..8ec1d956 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -2,8 +2,8 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionInsertResource; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; @@ -13,14 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; -import jakarta.validation.Valid; -import jakarta.validation.ValidationException; +import jakarta.persistence.EntityNotFoundException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.function.BiConsumer; -import static java.lang.String.join; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.*; @RestController @@ -59,13 +58,12 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse public ResponseEntity addCoopAssetsTransaction( final String currentUser, final String assumedRoles, - @Valid final HsOfficeCoopAssetsTransactionInsertResource requestBody) { + final HsOfficeCoopAssetsTransactionInsertResource requestBody) { context.define(currentUser, assumedRoles); validate(requestBody); - final var entityToSave = mapper.map(requestBody, HsOfficeCoopAssetsTransactionEntity.class); - + final var entityToSave = mapper.map(requestBody, HsOfficeCoopAssetsTransactionEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); final var saved = coopAssetsTransactionRepo.save(entityToSave); final var uri = @@ -98,9 +96,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse validateDebitTransaction(requestBody, violations); validateCreditTransaction(requestBody, violations); validateAssetValue(requestBody, violations); - if (violations.size() > 0) { - throw new ValidationException("[" + join(", ", violations) + "]"); - } + MultiValidationException.throwIfNotEmpty(violations); } private static void validateDebitTransaction( @@ -132,4 +128,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse } } -} + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + if ( resource.getReverseEntryUuid() != null ) { + entity.setAdjustedAssetTx(coopAssetsTransactionRepo.findByUuid(resource.getReverseEntryUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] reverseEntityUuid %s not found".formatted(resource.getReverseEntryUuid())))); + } + }; +}; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java index e91bc8bd..49487cd8 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java @@ -1,20 +1,36 @@ package net.hostsharing.hsadminng.hs.office.coopassets; -import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.errors.DisplayAs; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.GenericGenerator; import jakarta.persistence.*; +import java.io.IOException; import java.math.BigDecimal; import java.time.LocalDate; import java.util.UUID; import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -24,17 +40,18 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("CoopAssetsTransaction") -public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUuid { +@DisplayAs("CoopAssetsTransaction") +public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, BaseEntity { private static Stringify stringify = stringify(HsOfficeCoopAssetsTransactionEntity.class) - .withProp(HsOfficeCoopAssetsTransactionEntity::getMemberNumber) + .withIdProp(HsOfficeCoopAssetsTransactionEntity::getTaggedMemberNumber) .withProp(HsOfficeCoopAssetsTransactionEntity::getValueDate) .withProp(HsOfficeCoopAssetsTransactionEntity::getTransactionType) .withProp(HsOfficeCoopAssetsTransactionEntity::getAssetValue) .withProp(HsOfficeCoopAssetsTransactionEntity::getReference) .withProp(HsOfficeCoopAssetsTransactionEntity::getComment) - .withSeparator(", ") + .withProp(at -> ofNullable(at.getAdjustedAssetTx()).map(HsOfficeCoopAssetsTransactionEntity::toShortString).orElse(null)) + .withProp(at -> ofNullable(at.getAdjustmentAssetTx()).map(HsOfficeCoopAssetsTransactionEntity::toShortString).orElse(null)) .quotedValues(false); @Id @@ -42,6 +59,9 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUu @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") private UUID uuid; + @Version + private int version; + @ManyToOne @JoinColumn(name = "membershipuuid") private HsOfficeMembershipEntity membership; @@ -75,9 +95,25 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUu @Column(name = "comment") private String comment; + /** + * Optionally, the UUID of the corresponding transaction for an adjustment transaction. + */ + @OneToOne + @JoinColumn(name = "adjustedassettxuuid") + private HsOfficeCoopAssetsTransactionEntity adjustedAssetTx; - public Integer getMemberNumber() { - return ofNullable(membership).map(HsOfficeMembershipEntity::getMemberNumber).orElse(null); + @OneToOne(mappedBy = "adjustedAssetTx") + private HsOfficeCoopAssetsTransactionEntity adjustmentAssetTx; + + @Override + public HsOfficeCoopAssetsTransactionEntity load() { + BaseEntity.super.load(); + membership.load(); + return this; + } + + public String getTaggedMemberNumber() { + return ofNullable(membership).map(HsOfficeMembershipEntity::toShortString).orElse("M-???????"); } @Override @@ -87,6 +123,27 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUu @Override public String toShortString() { - return "%s%+1.2f".formatted(getMemberNumber(), assetValue); + return "%s:%.3s:%+1.2f".formatted( + getTaggedMemberNumber(), + transactionType, + ofNullable(assetValue).orElse(BigDecimal.ZERO)); + } + + public static RbacView rbac() { + return rbacViewFor("coopAssetsTransaction", HsOfficeCoopAssetsTransactionEntity.class) + .withIdentityView(RbacView.SQL.projection("reference")) + .withUpdatableColumns("comment") + .importEntityAlias("membership", HsOfficeMembershipEntity.class, usingDefaultCase(), + dependsOnColumn("membershipUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .toRole("membership", ADMIN).grantPermission(INSERT) + .toRole("membership", ADMIN).grantPermission(UPDATE) + .toRole("membership", AGENT).grantPermission(SELECT); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java index 813d8b92..78b41c9f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java @@ -1,9 +1,11 @@ package net.hostsharing.hsadminng.hs.office.coopshares; +import jakarta.persistence.EntityNotFoundException; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionResource; +import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; @@ -13,14 +15,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; -import jakarta.validation.Valid; -import jakarta.validation.ValidationException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.function.BiConsumer; -import static java.lang.String.join; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionTypeResource.CANCELLATION; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionTypeResource.SUBSCRIPTION; @@ -60,12 +60,12 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar public ResponseEntity addCoopSharesTransaction( final String currentUser, final String assumedRoles, - @Valid final HsOfficeCoopSharesTransactionInsertResource requestBody) { + final HsOfficeCoopSharesTransactionInsertResource requestBody) { context.define(currentUser, assumedRoles); validate(requestBody); - final var entityToSave = mapper.map(requestBody, HsOfficeCoopSharesTransactionEntity.class); + final var entityToSave = mapper.map(requestBody, HsOfficeCoopSharesTransactionEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); final var saved = coopSharesTransactionRepo.save(entityToSave); @@ -98,9 +98,7 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar validateSubscriptionTransaction(requestBody, violations); validateCancellationTransaction(requestBody, violations); validateshareCount(requestBody, violations); - if (violations.size() > 0) { - throw new ValidationException("[" + join(", ", violations) + "]"); - } + MultiValidationException.throwIfNotEmpty(violations); } private static void validateSubscriptionTransaction( @@ -132,4 +130,10 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar } } + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + if ( resource.getAdjustedShareTxUuid() != null ) { + entity.setAdjustedShareTx(coopSharesTransactionRepo.findByUuid(resource.getAdjustedShareTxUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] adjustedShareTxUuid %s not found".formatted(resource.getAdjustedShareTxUuid())))); + } + }; } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntity.java index f6a05bc4..aa650bd5 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntity.java @@ -1,17 +1,34 @@ package net.hostsharing.hsadminng.hs.office.coopshares; -import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.errors.DisplayAs; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import jakarta.persistence.*; +import java.io.IOException; import java.time.LocalDate; import java.util.UUID; import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -21,23 +38,27 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("CoopShareTransaction") -public class HsOfficeCoopSharesTransactionEntity implements Stringifyable, HasUuid { +@DisplayAs("CoopShareTransaction") +public class HsOfficeCoopSharesTransactionEntity implements Stringifyable, BaseEntity { private static Stringify stringify = stringify(HsOfficeCoopSharesTransactionEntity.class) - .withProp(HsOfficeCoopSharesTransactionEntity::getMemberNumber) + .withIdProp(HsOfficeCoopSharesTransactionEntity::getMemberNumberTagged) .withProp(HsOfficeCoopSharesTransactionEntity::getValueDate) .withProp(HsOfficeCoopSharesTransactionEntity::getTransactionType) .withProp(HsOfficeCoopSharesTransactionEntity::getShareCount) .withProp(HsOfficeCoopSharesTransactionEntity::getReference) .withProp(HsOfficeCoopSharesTransactionEntity::getComment) - .withSeparator(", ") - .quotedValues(false); + .withProp(at -> ofNullable(at.getAdjustedShareTx()).map(HsOfficeCoopSharesTransactionEntity::toShortString).orElse(null)) + .withProp(at -> ofNullable(at.getAdjustmentShareTx()).map(HsOfficeCoopSharesTransactionEntity::toShortString).orElse(null)) + .quotedValues(false); @Id @GeneratedValue private UUID uuid; + @Version + private int version; + @ManyToOne @JoinColumn(name = "membershipuuid") private HsOfficeMembershipEntity membership; @@ -71,17 +92,52 @@ public class HsOfficeCoopSharesTransactionEntity implements Stringifyable, HasUu @Column(name = "comment") private String comment; + /** + * Optionally, the UUID of the corresponding transaction for an adjustment transaction. + */ + @OneToOne + @JoinColumn(name = "adjustedsharetxuuid") + private HsOfficeCoopSharesTransactionEntity adjustedShareTx; + + @OneToOne(mappedBy = "adjustedShareTx") + private HsOfficeCoopSharesTransactionEntity adjustmentShareTx; + + @Override + public HsOfficeCoopSharesTransactionEntity load() { + BaseEntity.super.load(); + membership.load(); + return this; + } + @Override public String toString() { return stringify.apply(this); } - public Integer getMemberNumber() { - return ofNullable(membership).map(HsOfficeMembershipEntity::getMemberNumber).orElse(null); + private String getMemberNumberTagged() { + return ofNullable(membership).map(HsOfficeMembershipEntity::toShortString).orElse(null); } @Override public String toShortString() { - return "M-%s%+d".formatted(getMemberNumber(), shareCount); + return "%s:%.3s:%+d".formatted(getMemberNumberTagged(), transactionType, shareCount); + } + + public static RbacView rbac() { + return rbacViewFor("coopSharesTransaction", HsOfficeCoopSharesTransactionEntity.class) + .withIdentityView(SQL.projection("reference")) + .withUpdatableColumns("comment") + .importEntityAlias("membership", HsOfficeMembershipEntity.class, usingDefaultCase(), + dependsOnColumn("membershipUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .toRole("membership", ADMIN).grantPermission(INSERT) + .toRole("membership", ADMIN).grantPermission(UPDATE) + .toRole("membership", AGENT).grantPermission(SELECT); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java index bc4175ca..73fe78af 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java @@ -5,7 +5,12 @@ import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeDebitors import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorResource; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import org.apache.commons.lang3.Validate; +import org.hibernate.Hibernate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -14,9 +19,13 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import jakarta.validation.ValidationException; import java.util.List; import java.util.UUID; +import static net.hostsharing.hsadminng.errors.DisplayAs.DisplayName; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; + @RestController public class HsOfficeDebitorController implements HsOfficeDebitorsApi { @@ -30,6 +39,9 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi { @Autowired private HsOfficeDebitorRepository debitorRepo; + @Autowired + private HsOfficeRelationRealRepository relrealRepo; + @PersistenceContext private EntityManager em; @@ -53,22 +65,47 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi { @Override @Transactional public ResponseEntity addDebitor( - final String currentUser, - final String assumedRoles, - final HsOfficeDebitorInsertResource body) { + String currentUser, + String assumedRoles, + HsOfficeDebitorInsertResource body) { context.define(currentUser, assumedRoles); - final var entityToSave = mapper.map(body, HsOfficeDebitorEntity.class); + Validate.isTrue(body.getDebitorRel() == null || body.getDebitorRelUuid() == null, + "ERROR: [400] exactly one of debitorRel and debitorRelUuid must be supplied, but found both"); + Validate.isTrue(body.getDebitorRel() != null || body.getDebitorRelUuid() != null, + "ERROR: [400] exactly one of debitorRel and debitorRelUuid must be supplied, but found none"); + Validate.isTrue(body.getDebitorRel() == null || + body.getDebitorRel().getType() == null || DEBITOR.name().equals(body.getDebitorRel().getType()), + "ERROR: [400] debitorRel.type must be '"+DEBITOR.name()+"' or null for default"); + Validate.isTrue(body.getDebitorRel() == null || body.getDebitorRel().getMark() == null, + "ERROR: [400] debitorRel.mark must be null"); - final var saved = debitorRepo.save(entityToSave); + final var entityToSave = mapper.map(body, HsOfficeDebitorEntity.class); + if ( body.getDebitorRel() != null ) { + body.getDebitorRel().setType(DEBITOR.name()); + final var debitorRel = mapper.map(body.getDebitorRel(), HsOfficeRelationRealEntity.class); + validateEntityExists("debitorRel.anchorUuid", debitorRel.getAnchor()); + validateEntityExists("debitorRel.holderUuid", debitorRel.getHolder()); + validateEntityExists("debitorRel.contactUuid", debitorRel.getContact()); + entityToSave.setDebitorRel(relrealRepo.save(debitorRel)); + } else { + final var debitorRelOptional = relrealRepo.findByUuid(body.getDebitorRelUuid()); + debitorRelOptional.ifPresentOrElse( + debitorRel -> {entityToSave.setDebitorRel(relrealRepo.save(debitorRel));}, + () -> { throw new ValidationException("Unable to find RealRelation by debitorRelUuid: " + body.getDebitorRelUuid());}); + } + + final var savedEntity = debitorRepo.save(entityToSave); + em.flush(); + em.refresh(savedEntity); final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/hs/office/debitors/{id}") - .buildAndExpand(saved.getUuid()) + .buildAndExpand(savedEntity.getUuid()) .toUri(); - final var mapped = mapper.map(saved, HsOfficeDebitorResource.class); + final var mapped = mapper.map(savedEntity, HsOfficeDebitorResource.class); return ResponseEntity.created(uri).body(mapped); } @@ -119,7 +156,19 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi { new HsOfficeDebitorEntityPatcher(em, current).apply(body); final var saved = debitorRepo.save(current); + Hibernate.initialize(saved); final var mapped = mapper.map(saved, HsOfficeDebitorResource.class); return ResponseEntity.ok(mapped); } + + // TODO.impl: extract this to some generally usable class? + private > T validateEntityExists(final String property, final T entitySkeleton) { + final var foundEntity = em.find(entitySkeleton.getClass(), entitySkeleton.getUuid()); + if ( foundEntity == null) { + throw new ValidationException("Unable to find " + DisplayName.of(entitySkeleton) + " by " + property + ": " + entitySkeleton.getUuid()); + } + + //noinspection unchecked + return (T) foundEntity; + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java index 279f1d63..192f3f2e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java @@ -1,42 +1,75 @@ package net.hostsharing.hsadminng.hs.office.debitor; -import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.errors.DisplayAs; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.JoinFormula; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; -import jakarta.persistence.*; -import java.util.Optional; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import jakarta.validation.constraints.Pattern; +import java.io.IOException; import java.util.UUID; +import static jakarta.persistence.CascadeType.DETACH; +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.CascadeType.REFRESH; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NULLABLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @Table(name = "hs_office_debitor_rv") @Getter @Setter -@Builder +@Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor -@DisplayName("Debitor") -public class HsOfficeDebitorEntity implements HasUuid, Stringifyable { +@DisplayAs("Debitor") +public class HsOfficeDebitorEntity implements BaseEntity, Stringifyable { public static final String DEBITOR_NUMBER_TAG = "D-"; + public static final String TWO_DECIMAL_DIGITS = "^([0-9]{2})$"; - // TODO: I would rather like to generate something matching this example: - // debitor(1234500: Test AG, tes) - // maybe remove withSepararator (always use ', ') and add withBusinessIdProp (with ': ' afterwards)? private static Stringify stringify = stringify(HsOfficeDebitorEntity.class, "debitor") - .withProp(e -> DEBITOR_NUMBER_TAG + e.getDebitorNumber()) - .withProp(HsOfficeDebitorEntity::getPartner) + .withIdProp(HsOfficeDebitorEntity::toShortString) + .withProp(e -> ofNullable(e.getDebitorRel()).map(HsOfficeRelation::toShortString).orElse(null)) .withProp(HsOfficeDebitorEntity::getDefaultPrefix) - .withSeparator(": ") .quotedValues(false); @Id @@ -44,16 +77,33 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable { @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") private UUID uuid; - @ManyToOne - @JoinColumn(name = "partneruuid") + @Version + private int version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinFormula( + referencedColumnName = "uuid", + value = """ + ( + SELECT DISTINCT partner.uuid + FROM hs_office_partner_rv partner + JOIN hs_office_relation_rv dRel + ON dRel.uuid = debitorreluuid AND dRel.type = 'DEBITOR' + JOIN hs_office_relation_rv pRel + ON pRel.uuid = partner.partnerRelUuid AND pRel.type = 'PARTNER' + WHERE pRel.holderUuid = dRel.anchorUuid + ) + """) + @NotFound(action = NotFoundAction.IGNORE) // TODO.impl: map a simplified raw-PartnerEntity, just for the partner-number private HsOfficePartnerEntity partner; - @Column(name = "debitornumbersuffix", columnDefinition = "numeric(2)") - private Byte debitorNumberSuffix; // TODO maybe rather as a formatted String? + @Column(name = "debitornumbersuffix", length = 2) + @Pattern(regexp = TWO_DECIMAL_DIGITS) + private String debitorNumberSuffix; - @ManyToOne - @JoinColumn(name = "billingcontactuuid") - private HsOfficeContactEntity billingContact; // TODO: migrate to billingPerson + @ManyToOne(cascade = { PERSIST, MERGE, REFRESH, DETACH }, optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "debitorreluuid", nullable = false) + private HsOfficeRelationRealEntity debitorRel; @Column(name = "billable", nullable = false) private Boolean billable; // not a primitive because otherwise the default would be false @@ -70,22 +120,38 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable { @Column(name = "vatreversecharge") private boolean vatReverseCharge; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "refundbankaccountuuid") + @NotFound(action = NotFoundAction.IGNORE) private HsOfficeBankAccountEntity refundBankAccount; @Column(name = "defaultprefix", columnDefinition = "char(3) not null") private String defaultPrefix; - private String getDebitorNumberString() { - if (partner == null || partner.getPartnerNumber() == null || debitorNumberSuffix == null ) { - return null; + @Override + public HsOfficeDebitorEntity load() { + BaseEntity.super.load(); + if (partner != null) { + partner.load(); } - return partner.getPartnerNumber() + String.format("%02d", debitorNumberSuffix); + debitorRel.load(); + if (refundBankAccount != null) { + refundBankAccount.load(); + } + return this; + } + + private String getDebitorNumberString() { + return ofNullable(partner) + .filter(partner -> debitorNumberSuffix != null) + .map(HsOfficePartnerEntity::getPartnerNumber) + .map(Object::toString) + .map(partnerNumber -> partnerNumber + debitorNumberSuffix) + .orElse(null); } public Integer getDebitorNumber() { - return Optional.ofNullable(getDebitorNumberString()).map(Integer::parseInt).orElse(null); + return ofNullable(getDebitorNumberString()).map(Integer::parseInt).orElse(null); } @Override @@ -97,4 +163,68 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable { public String toShortString() { return DEBITOR_NUMBER_TAG + getDebitorNumberString(); } + + public static RbacView rbac() { + return rbacViewFor("debitor", HsOfficeDebitorEntity.class) + .withIdentityView(SQL.query(""" + SELECT debitor.uuid AS uuid, + 'D-' || (SELECT partner.partnerNumber + FROM hs_office_partner partner + JOIN hs_office_relation partnerRel + ON partnerRel.uuid = partner.partnerRelUUid AND partnerRel.type = 'PARTNER' + JOIN hs_office_relation debitorRel + ON debitorRel.anchorUuid = partnerRel.holderUuid AND debitorRel.type = 'DEBITOR' + WHERE debitorRel.uuid = debitor.debitorRelUuid) + || debitorNumberSuffix as idName + FROM hs_office_debitor AS debitor + """)) + .withRestrictedViewOrderBy(SQL.projection("defaultPrefix")) + .withUpdatableColumns( + "debitorRelUuid", + "billable", + "refundBankAccountUuid", + "vatId", + "vatCountryCode", + "vatBusiness", + "vatReverseCharge", + "defaultPrefix") + .toRole("global", ADMIN).grantPermission(INSERT) + + .importRootEntityAliasProxy("debitorRel", HsOfficeRelationRbacEntity.class, usingCase(DEBITOR), + directlyFetchedByDependsOnColumn(), + dependsOnColumn("debitorRelUuid")) + .createPermission(DELETE).grantedTo("debitorRel", OWNER) + .createPermission(UPDATE).grantedTo("debitorRel", ADMIN) + .createPermission(SELECT).grantedTo("debitorRel", TENANT) + + .importEntityAlias("refundBankAccount", HsOfficeBankAccountEntity.class, usingDefaultCase(), + dependsOnColumn("refundBankAccountUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("refundBankAccount", ADMIN).grantRole("debitorRel", AGENT) + .toRole("debitorRel", AGENT).grantRole("refundBankAccount", REFERRER) + + .importEntityAlias("partnerRel", HsOfficeRelationRbacEntity.class, usingDefaultCase(), + dependsOnColumn("debitorRelUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_relation AS partnerRel + JOIN hs_office_relation AS debitorRel + ON debitorRel.type = 'DEBITOR' AND debitorRel.anchorUuid = partnerRel.holderUuid + WHERE partnerRel.type = 'PARTNER' + AND ${REF}.debitorRelUuid = debitorRel.uuid + """), + NOT_NULL) + .toRole("partnerRel", ADMIN).grantRole("debitorRel", ADMIN) + .toRole("partnerRel", AGENT).grantRole("debitorRel", AGENT) + .toRole("debitorRel", AGENT).grantRole("partnerRel", TENANT) + .declarePlaceholderEntityAliases("partnerPerson", "operationalPerson") + .forExampleRole("partnerPerson", ADMIN).wouldBeGrantedTo("partnerRel", ADMIN) + .forExampleRole("operationalPerson", ADMIN).wouldBeGrantedTo("partnerRel", ADMIN) + .forExampleRole("partnerRel", TENANT).wouldBeGrantedTo("partnerPerson", REFERRER); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/506-debitor/5063-hs-office-debitor-rbac"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java index 914c8230..d8d67943 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java @@ -1,8 +1,8 @@ package net.hostsharing.hsadminng.hs.office.debitor; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.OptionalFromJson; @@ -23,9 +23,9 @@ class HsOfficeDebitorEntityPatcher implements EntityPatcher { - verifyNotNull(newValue, "billingContact"); - entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, newValue)); + OptionalFromJson.of(resource.getDebitorRelUuid()).ifPresent(newValue -> { + verifyNotNull(newValue, "debitorRel"); + entity.setDebitorRel(em.getReference(HsOfficeRelationRealEntity.class, newValue)); }); Optional.ofNullable(resource.getBillable()).ifPresent(entity::setBillable); OptionalFromJson.of(resource.getVatId()).ifPresent(entity::setVatId); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java index 64be98b1..bb6cd0f2 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java @@ -13,7 +13,10 @@ public interface HsOfficeDebitorRepository extends Repository findDebitorByDebitorNumber(int partnerNumber, byte debitorNumberSuffix); @@ -24,15 +27,21 @@ public interface HsOfficeDebitorRepository extends Repository findDebitorByOptionalNameLike(String name); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java index e18fc183..3c783aae 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -12,9 +12,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; @@ -32,9 +29,6 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi { @Autowired private HsOfficeMembershipRepository membershipRepo; - @PersistenceContext - private EntityManager em; - @Override @Transactional(readOnly = true) public ResponseEntity> listMemberships( @@ -58,7 +52,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi { public ResponseEntity addMembership( final String currentUser, final String assumedRoles, - @Valid final HsOfficeMembershipInsertResource body) { + final HsOfficeMembershipInsertResource body) { context.define(currentUser, assumedRoles); @@ -121,7 +115,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi { final var current = membershipRepo.findByUuid(membershipUuid).orElseThrow(); - new HsOfficeMembershipEntityPatcher(em, mapper, current).apply(body); + new HsOfficeMembershipEntityPatcher(mapper, current).apply(body); final var saved = membershipRepo.save(current); final var mapped = mapper.map(saved, HsOfficeMembershipResource.class, SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java index 355b79a9..9d05f5f9 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntity.java @@ -1,23 +1,57 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.PostgreSQLRangeType; -import com.vladmihalcea.hibernate.type.range.Range; -import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType; +import io.hypersistence.utils.hibernate.type.range.Range; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; -import org.hibernate.annotations.Fetch; -import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.Type; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import jakarta.validation.constraints.Pattern; +import java.io.IOException; import java.time.LocalDate; import java.util.UUID; -import static net.hostsharing.hsadminng.mapper.PostgresDateRange.*; +import static io.hypersistence.utils.hibernate.type.range.Range.emptyRange; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.upperInclusiveFromPostgresDateRange; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -27,34 +61,32 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("Membership") -public class HsOfficeMembershipEntity implements HasUuid, Stringifyable { +@DisplayAs("Membership") +public class HsOfficeMembershipEntity implements BaseEntity, Stringifyable { public static final String MEMBER_NUMBER_TAG = "M-"; + public static final String TWO_DECIMAL_DIGITS = "^([0-9]{2})$"; private static Stringify stringify = stringify(HsOfficeMembershipEntity.class) .withProp(e -> MEMBER_NUMBER_TAG + e.getMemberNumber()) - .withProp(e -> e.getPartner().toShortString()) - .withProp(e -> e.getMainDebitor().toShortString()) + .withProp(HsOfficeMembershipEntity::getPartner) .withProp(e -> e.getValidity().asString()) - .withProp(HsOfficeMembershipEntity::getReasonForTermination) - .withSeparator(", ") + .withProp(HsOfficeMembershipEntity::getStatus) .quotedValues(false); @Id @GeneratedValue private UUID uuid; - @ManyToOne + @Version + private int version; + + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "partneruuid") private HsOfficePartnerEntity partner; - @ManyToOne - @Fetch(FetchMode.JOIN) - @JoinColumn(name = "maindebitoruuid") - private HsOfficeDebitorEntity mainDebitor; - @Column(name = "membernumbersuffix", length = 2) + @Pattern(regexp = TWO_DECIMAL_DIGITS) private String memberNumberSuffix; @Column(name = "validity", columnDefinition = "daterange") @@ -64,9 +96,16 @@ public class HsOfficeMembershipEntity implements HasUuid, Stringifyable { @Column(name = "membershipfeebillable", nullable = false) private Boolean membershipFeeBillable; // not primitive to force setting the value - @Column(name = "reasonfortermination") + @Column(name = "status") @Enumerated(EnumType.STRING) - private HsOfficeReasonForTermination reasonForTermination; + private HsOfficeMembershipStatus status; + + @Override + public HsOfficeMembershipEntity load() { + BaseEntity.super.load(); + partner.load(); + return this; + } public void setValidFrom(final LocalDate validFrom) { setValidity(toPostgresDateRange(validFrom, getValidTo())); @@ -86,7 +125,7 @@ public class HsOfficeMembershipEntity implements HasUuid, Stringifyable { public Range getValidity() { if (validity == null) { - validity = Range.infinite(LocalDate.class); + validity = emptyRange(LocalDate.class); } return validity; } @@ -110,8 +149,49 @@ public class HsOfficeMembershipEntity implements HasUuid, Stringifyable { @PrePersist void init() { - if (getReasonForTermination() == null) { - setReasonForTermination(HsOfficeReasonForTermination.NONE); + if (getStatus() == null) { + setStatus(HsOfficeMembershipStatus.INVALID); } } + + public static RbacView rbac() { + return rbacViewFor("membership", HsOfficeMembershipEntity.class) + .withIdentityView(SQL.query(""" + SELECT m.uuid AS uuid, + 'M-' || p.partnerNumber || m.memberNumberSuffix as idName + FROM hs_office_membership AS m + JOIN hs_office_partner AS p ON p.uuid = m.partnerUuid + """)) + .withRestrictedViewOrderBy(SQL.projection("validity")) + .withUpdatableColumns("validity", "membershipFeeBillable", "status") + + .importEntityAlias("partnerRel", HsOfficeRelationRbacEntity.class, usingDefaultCase(), + dependsOnColumn("partnerUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_partner AS partner + JOIN hs_office_relation AS partnerRel ON partnerRel.uuid = partner.partnerRelUuid + WHERE partner.uuid = ${REF}.partnerUuid + """), + NOT_NULL) + .toRole("global", ADMIN).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + }) + .createSubRole(ADMIN, (with) -> { + with.incomingSuperRole("partnerRel", ADMIN); + with.permission(DELETE); + with.permission(UPDATE); + }) + .createSubRole(AGENT, (with) -> { + with.incomingSuperRole("partnerRel", AGENT); + with.outgoingSubRole("partnerRel", TENANT); + with.permission(SELECT); + }); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/510-membership/5103-hs-office-membership-rbac"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcher.java index 59fa6070..cbecb800 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcher.java @@ -1,49 +1,32 @@ package net.hostsharing.hsadminng.hs.office.membership; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.Mapper; import net.hostsharing.hsadminng.mapper.OptionalFromJson; -import jakarta.persistence.EntityManager; import java.util.Optional; -import java.util.UUID; public class HsOfficeMembershipEntityPatcher implements EntityPatcher { - private final EntityManager em; private final Mapper mapper; private final HsOfficeMembershipEntity entity; public HsOfficeMembershipEntityPatcher( - final EntityManager em, final Mapper mapper, final HsOfficeMembershipEntity entity) { - this.em = em; this.mapper = mapper; this.entity = entity; } @Override public void apply(final HsOfficeMembershipPatchResource resource) { - OptionalFromJson.of(resource.getMainDebitorUuid()) - .ifPresent(newValue -> { - verifyNotNull(newValue, "debitor"); - entity.setMainDebitor(em.getReference(HsOfficeDebitorEntity.class, newValue)); - }); OptionalFromJson.of(resource.getValidTo()).ifPresent( entity::setValidTo); - Optional.ofNullable(resource.getReasonForTermination()) - .map(v -> mapper.map(v, HsOfficeReasonForTermination.class)) - .ifPresent(entity::setReasonForTermination); + Optional.ofNullable(resource.getStatus()) + .map(v -> mapper.map(v, HsOfficeMembershipStatus.class)) + .ifPresent(entity::setStatus); OptionalFromJson.of(resource.getMembershipFeeBillable()).ifPresent( entity::setMembershipFeeBillable); } - - private void verifyNotNull(final UUID newValue, final String propertyName) { - if (newValue == null) { - throw new IllegalArgumentException("property '" + propertyName + "' must not be null"); - } - } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipStatus.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipStatus.java new file mode 100644 index 00000000..b44ceee3 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipStatus.java @@ -0,0 +1,5 @@ +package net.hostsharing.hsadminng.hs.office.membership; + +public enum HsOfficeMembershipStatus { + INVALID, ACTIVE, CANCELLED, TRANSFERRED, DECEASED, LIQUIDATED, EXPULSED, UNKNOWN; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java deleted file mode 100644 index a2a41051..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeReasonForTermination.java +++ /dev/null @@ -1,5 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.membership; - -public enum HsOfficeReasonForTermination { - NONE, CANCELLATION, TRANSFER, DEATH, LIQUIDATION, EXPULSION, UNKNOWN; -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/migration/HasUuid.java b/src/main/java/net/hostsharing/hsadminng/hs/office/migration/HasUuid.java deleted file mode 100644 index 97e3eff1..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/migration/HasUuid.java +++ /dev/null @@ -1,7 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.migration; - -import java.util.UUID; - -public interface HasUuid { - UUID getUuid(); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java index 42b7afe9..5965d990 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java @@ -1,12 +1,21 @@ package net.hostsharing.hsadminng.hs.office.partner; import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.errors.ReferenceNotFoundException; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePartnersApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerRelInsertResource; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; @@ -17,6 +26,8 @@ import jakarta.persistence.PersistenceContext; import java.util.List; import java.util.UUID; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.EX_PARTNER; + @RestController public class HsOfficePartnerController implements HsOfficePartnersApi { @@ -30,6 +41,9 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { @Autowired private HsOfficePartnerRepository partnerRepo; + @Autowired + private HsOfficeRelationRealRepository relationRepo; + @PersistenceContext private EntityManager em; @@ -56,7 +70,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { context.define(currentUser, assumedRoles); - final var entityToSave = mapper.map(body, HsOfficePartnerEntity.class); + final var entityToSave = createPartnerEntity(body); final var saved = partnerRepo.save(entityToSave); @@ -93,11 +107,15 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { final UUID partnerUuid) { context.define(currentUser, assumedRoles); - final var result = partnerRepo.deleteByUuid(partnerUuid); - if (result == 0) { + final var partnerToDelete = partnerRepo.findByUuid(partnerUuid); + if (partnerToDelete.isEmpty()) { return ResponseEntity.notFound().build(); } + if (partnerRepo.deleteByUuid(partnerUuid) != 1) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.noContent().build(); } @@ -112,11 +130,46 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { context.define(currentUser, assumedRoles); final var current = partnerRepo.findByUuid(partnerUuid).orElseThrow(); + final var previousPartnerRel = current.getPartnerRel(); new HsOfficePartnerEntityPatcher(em, current).apply(body); final var saved = partnerRepo.save(current); + optionallyCreateExPartnerRelation(saved, previousPartnerRel); + final var mapped = mapper.map(saved, HsOfficePartnerResource.class); return ResponseEntity.ok(mapped); } + + private void optionallyCreateExPartnerRelation(final HsOfficePartnerEntity saved, final HsOfficeRelationRealEntity previousPartnerRel) { + if (!saved.getPartnerRel().getUuid().equals(previousPartnerRel.getUuid())) { + relationRepo.save(previousPartnerRel.toBuilder().uuid(null).type(EX_PARTNER).build()); + } + } + + private HsOfficePartnerEntity createPartnerEntity(final HsOfficePartnerInsertResource body) { + final var entityToSave = new HsOfficePartnerEntity(); + entityToSave.setPartnerNumber(body.getPartnerNumber()); + entityToSave.setPartnerRel(persistPartnerRel(body.getPartnerRel())); + entityToSave.setDetails(mapper.map(body.getDetails(), HsOfficePartnerDetailsEntity.class)); + return entityToSave; + } + + private HsOfficeRelationRealEntity persistPartnerRel(final HsOfficePartnerRelInsertResource resource) { + final var entity = new HsOfficeRelationRealEntity(); + entity.setType(HsOfficeRelationType.PARTNER); + entity.setAnchor(ref(HsOfficePersonEntity.class, resource.getAnchorUuid())); + entity.setHolder(ref(HsOfficePersonEntity.class, resource.getHolderUuid())); + entity.setContact(ref(HsOfficeContactRealEntity.class, resource.getContactUuid())); + em.persist(entity); + return entity; + } + + private E ref(final Class entityClass, final UUID uuid) { + try { + return em.getReference(entityClass, uuid); + } catch (final Throwable exc) { + throw new ReferenceNotFoundException(entityClass, uuid, exc); + } + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java index ea09eb44..1ef8cb8f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java @@ -1,15 +1,21 @@ package net.hostsharing.hsadminng.hs.office.partner; import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import jakarta.persistence.*; +import java.io.IOException; import java.time.LocalDate; import java.util.UUID; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -19,8 +25,8 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("PartnerDetails") -public class HsOfficePartnerDetailsEntity implements HasUuid, Stringifyable { +@DisplayAs("PartnerDetails") +public class HsOfficePartnerDetailsEntity implements BaseEntity, Stringifyable { private static Stringify stringify = stringify( HsOfficePartnerDetailsEntity.class, @@ -31,13 +37,13 @@ public class HsOfficePartnerDetailsEntity implements HasUuid, Stringifyable { .withProp(HsOfficePartnerDetailsEntity::getBirthday) .withProp(HsOfficePartnerDetailsEntity::getBirthName) .withProp(HsOfficePartnerDetailsEntity::getDateOfDeath) - .withSeparator(", ") .quotedValues(false); @Id @GeneratedValue private UUID uuid; + private @Version int version; private @Column(name = "registrationoffice") String registrationOffice; private @Column(name = "registrationnumber") String registrationNumber; private @Column(name = "birthname") String birthName; @@ -55,6 +61,36 @@ public class HsOfficePartnerDetailsEntity implements HasUuid, Stringifyable { return registrationNumber != null ? registrationNumber : birthName != null ? birthName : birthday != null ? birthday.toString() - : dateOfDeath != null ? dateOfDeath.toString() : ""; + : dateOfDeath != null ? dateOfDeath.toString() + : ""; + } + + + public static RbacView rbac() { + return rbacViewFor("partnerDetails", HsOfficePartnerDetailsEntity.class) + .withIdentityView(SQL.query(""" + SELECT partnerDetails.uuid as uuid, partner_iv.idName as idName + FROM hs_office_partner_details AS partnerDetails + JOIN hs_office_partner partner ON partner.detailsUuid = partnerDetails.uuid + JOIN hs_office_partner_iv partner_iv ON partner_iv.uuid = partner.uuid + """)) + .withRestrictedViewOrderBy(SQL.expression("uuid")) + .withUpdatableColumns( + "registrationOffice", + "registrationNumber", + "birthPlace", + "birthName", + "birthday", + "dateOfDeath") + .toRole("global", ADMIN).grantPermission(INSERT) + + // The grants are defined in HsOfficePartnerEntity.rbac() + // because they have to be changed when its partnerRel changes, + // not when anything in partner details changes. + ; + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/504-partner/5044-hs-office-partner-details-rbac"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java index 850b94db..5e199d0c 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java @@ -1,19 +1,37 @@ package net.hostsharing.hsadminng.hs.office.partner; -import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContact; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.NotFound; import org.hibernate.annotations.NotFoundAction; import jakarta.persistence.*; -import java.util.Optional; +import java.io.IOException; import java.util.UUID; +import static jakarta.persistence.CascadeType.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; +import static java.util.Optional.ofNullable; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -23,35 +41,54 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("Partner") -public class HsOfficePartnerEntity implements Stringifyable, HasUuid { +@DisplayAs("Partner") +public class HsOfficePartnerEntity implements Stringifyable, BaseEntity { + + public static final String PARTNER_NUMBER_TAG = "P-"; private static Stringify stringify = stringify(HsOfficePartnerEntity.class, "partner") - .withProp(HsOfficePartnerEntity::getPerson) - .withProp(HsOfficePartnerEntity::getContact) - .withSeparator(": ") + .withIdProp(HsOfficePartnerEntity::toShortString) + .withProp(p -> ofNullable(p.getPartnerRel()) + .map(HsOfficeRelation::getHolder) + .map(HsOfficePersonEntity::toShortString) + .orElse(null)) + .withProp(p -> ofNullable(p.getPartnerRel()) + .map(HsOfficeRelation::getContact) + .map(HsOfficeContact::toShortString) + .orElse(null)) .quotedValues(false); @Id @GeneratedValue private UUID uuid; + @Version + private int version; + @Column(name = "partnernumber", columnDefinition = "numeric(5) not null") private Integer partnerNumber; - @ManyToOne - @JoinColumn(name = "personuuid", nullable = false) - private HsOfficePersonEntity person; + @ManyToOne(cascade = { PERSIST, MERGE, REFRESH, DETACH }, optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "partnerreluuid", nullable = false) + private HsOfficeRelationRealEntity partnerRel; - @ManyToOne - @JoinColumn(name = "contactuuid", nullable = false) - private HsOfficeContactEntity contact; - - @ManyToOne(cascade = { CascadeType.PERSIST, CascadeType.MERGE, CascadeType.DETACH }, optional = true) + @ManyToOne(cascade = { PERSIST, MERGE, REFRESH, DETACH }, optional = true, fetch = FetchType.LAZY) @JoinColumn(name = "detailsuuid") @NotFound(action = NotFoundAction.IGNORE) private HsOfficePartnerDetailsEntity details; + @Override + public HsOfficePartnerEntity load() { + BaseEntity.super.load(); + partnerRel.load(); + details.load(); + return this; + } + + public String getTaggedPartnerNumber() { + return PARTNER_NUMBER_TAG + partnerNumber; + } + @Override public String toString() { return stringify.apply(this); @@ -59,6 +96,32 @@ public class HsOfficePartnerEntity implements Stringifyable, HasUuid { @Override public String toShortString() { - return Optional.ofNullable(person).map(HsOfficePersonEntity::toShortString).orElse(""); + return getTaggedPartnerNumber(); + } + + public static RbacView rbac() { + return rbacViewFor("partner", HsOfficePartnerEntity.class) + .withIdentityView(SQL.projection("'P-' || partnerNumber")) + .withUpdatableColumns("partnerRelUuid") + .toRole("global", ADMIN).grantPermission(INSERT) + + .importRootEntityAliasProxy("partnerRel", HsOfficeRelationRbacEntity.class, + usingDefaultCase(), + directlyFetchedByDependsOnColumn(), + dependsOnColumn("partnerRelUuid")) + .createPermission(DELETE).grantedTo("partnerRel", OWNER) + .createPermission(UPDATE).grantedTo("partnerRel", ADMIN) + .createPermission(SELECT).grantedTo("partnerRel", TENANT) + + .importSubEntityAlias("partnerDetails", HsOfficePartnerDetailsEntity.class, + directlyFetchedByDependsOnColumn(), + dependsOnColumn("detailsUuid")) + .createPermission("partnerDetails", DELETE).grantedTo("partnerRel", OWNER) + .createPermission("partnerDetails", UPDATE).grantedTo("partnerRel", AGENT) + .createPermission("partnerDetails", SELECT).grantedTo("partnerRel", AGENT); // not TENANT! + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/504-partner/5043-hs-office-partner-rbac"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java index bc5de4d7..b01ccc6b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java @@ -1,13 +1,11 @@ package net.hostsharing.hsadminng.hs.office.partner; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.OptionalFromJson; import jakarta.persistence.EntityManager; -import java.util.UUID; class HsOfficePartnerEntityPatcher implements EntityPatcher { private final EntityManager em; @@ -21,19 +19,15 @@ class HsOfficePartnerEntityPatcher implements EntityPatcher { - verifyNotNull(newValue, "contact"); - entity.setContact(em.getReference(HsOfficeContactEntity.class, newValue)); - }); - OptionalFromJson.of(resource.getPersonUuid()).ifPresent(newValue -> { - verifyNotNull(newValue, "person"); - entity.setPerson(em.getReference(HsOfficePersonEntity.class, newValue)); + OptionalFromJson.of(resource.getPartnerRelUuid()).ifPresent(newValue -> { + verifyNotNull(newValue, "partnerRel"); + entity.setPartnerRel(em.getReference(HsOfficeRelationRealEntity.class, newValue)); }); new HsOfficePartnerDetailsEntityPatcher(em, entity.getDetails()).apply(resource.getDetails()); } - private void verifyNotNull(final UUID newValue, final String propertyName) { + private void verifyNotNull(final Object newValue, final String propertyName) { if (newValue == null) { throw new IllegalArgumentException("property '" + propertyName + "' must not be null"); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java index dfbd1667..2c5913a5 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java @@ -11,13 +11,16 @@ public interface HsOfficePartnerRepository extends Repository findByUuid(UUID id); + List findAll(); // TODO.impl: move to a repo in test sources + @Query(""" SELECT partner FROM HsOfficePartnerEntity partner - JOIN HsOfficeContactEntity contact ON contact.uuid = partner.contact.uuid - JOIN HsOfficePersonEntity person ON person.uuid = partner.person.uuid + JOIN HsOfficeRelationRealEntity rel ON rel.uuid = partner.partnerRel.uuid + JOIN HsOfficeContactRealEntity contact ON contact.uuid = rel.contact.uuid + JOIN HsOfficePersonEntity person ON person.uuid = rel.holder.uuid WHERE :name is null OR partner.details.birthName like concat(cast(:name as text), '%') - OR contact.label like concat(cast(:name as text), '%') + OR contact.caption like concat(cast(:name as text), '%') OR person.tradeName like concat(cast(:name as text), '%') OR person.givenName like concat(cast(:name as text), '%') OR person.familyName like concat(cast(:name as text), '%') diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java index 409ef07d..c3a52c50 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java @@ -33,10 +33,10 @@ public class HsOfficePersonController implements HsOfficePersonsApi { public ResponseEntity> listPersons( final String currentUser, final String assumedRoles, - final String label) { + final String caption) { context.define(currentUser, assumedRoles); - final var entities = personRepo.findPersonByOptionalNameLike(label); + final var entities = personRepo.findPersonByOptionalNameLike(caption); final var resources = mapper.mapList(entities, HsOfficePersonResource.class); return ResponseEntity.ok(resources); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java index 2803136b..dd21a5c3 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java @@ -2,15 +2,23 @@ package net.hostsharing.hsadminng.hs.office.person; import lombok.*; import lombok.experimental.FieldNameConstants; -import net.hostsharing.hsadminng.errors.DisplayName; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.apache.commons.lang3.StringUtils; import jakarta.persistence.*; +import java.io.IOException; import java.util.UUID; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -21,12 +29,14 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @NoArgsConstructor @AllArgsConstructor @FieldNameConstants -@DisplayName("Person") -public class HsOfficePersonEntity implements HasUuid, Stringifyable { +@DisplayAs("Person") +public class HsOfficePersonEntity implements BaseEntity, Stringifyable { private static Stringify toString = stringify(HsOfficePersonEntity.class, "person") .withProp(Fields.personType, HsOfficePersonEntity::getPersonType) .withProp(Fields.tradeName, HsOfficePersonEntity::getTradeName) + .withProp(Fields.salutation, HsOfficePersonEntity::getSalutation) + .withProp(Fields.title, HsOfficePersonEntity::getTitle) .withProp(Fields.familyName, HsOfficePersonEntity::getFamilyName) .withProp(Fields.givenName, HsOfficePersonEntity::getGivenName); @@ -34,12 +44,21 @@ public class HsOfficePersonEntity implements HasUuid, Stringifyable { @GeneratedValue private UUID uuid; + @Version + private int version; + @Column(name = "persontype") private HsOfficePersonType personType; @Column(name = "tradename") private String tradeName; + @Column(name = "salutation") + private String salutation; + + @Column(name = "title") + private String title; + @Column(name = "familyname") private String familyName; @@ -56,4 +75,28 @@ public class HsOfficePersonEntity implements HasUuid, Stringifyable { return personType + " " + (!StringUtils.isEmpty(tradeName) ? tradeName : (familyName + ", " + givenName)); } + + public static RbacView rbac() { + return rbacViewFor("person", HsOfficePersonEntity.class) + .withIdentityView(SQL.projection("concat(tradeName, familyName, givenName)")) + .withUpdatableColumns("personType", "title", "salutation", "tradeName", "givenName", "familyName") + .toRole("global", GUEST).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.permission(DELETE); + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(REFERRER, (with) -> { + with.permission(SELECT); + }); + } + + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/502-person/5023-hs-office-person-rbac"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcher.java index d1d3fa8c..ede3fc03 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcher.java @@ -22,6 +22,8 @@ class HsOfficePersonEntityPatcher implements EntityPatcher, Stringifyable { + + private static Stringify toString = stringify(HsOfficeRelation.class, "rel") + .withProp(Fields.anchor, HsOfficeRelation::getAnchor) + .withProp(Fields.type, HsOfficeRelation::getType) + .withProp(Fields.mark, HsOfficeRelation::getMark) + .withProp(Fields.holder, HsOfficeRelation::getHolder) + .withProp(Fields.contact, HsOfficeRelation::getContact); + + private static Stringify toShortString = stringify(HsOfficeRelation.class, "rel") + .withProp(Fields.anchor, HsOfficeRelation::getAnchor) + .withProp(Fields.type, HsOfficeRelation::getType) + .withProp(Fields.holder, HsOfficeRelation::getHolder); + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "anchoruuid") + private HsOfficePersonEntity anchor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "holderuuid") + private HsOfficePersonEntity holder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contactuuid") + private HsOfficeContactRealEntity contact; + + @Column(name = "type") + @Enumerated(EnumType.STRING) + private HsOfficeRelationType type; + + @Column(name = "mark") + private String mark; + + @Override + public HsOfficeRelation load() { + BaseEntity.super.load(); + anchor.load(); + holder.load(); + contact.load(); + return this; + } + + @Override + public String toString() { + return toString.apply(this); + } + + @Override + public String toShortString() { + return toShortString.apply(this); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java new file mode 100644 index 00000000..a3f4d136 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -0,0 +1,153 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.function.BiConsumer; + + +@RestController + +public class HsOfficeRelationController implements HsOfficeRelationsApi { + + @Autowired + private Context context; + + @Autowired + private Mapper mapper; + + @Autowired + private HsOfficeRelationRbacRepository relationRbacRepo; + + @Autowired + private HsOfficePersonRepository holderRepo; + + @Autowired + private HsOfficeContactRealRepository contactrealRepo; + + @PersistenceContext + private EntityManager em; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listRelations( + final String currentUser, + final String assumedRoles, + final UUID personUuid, + final HsOfficeRelationTypeResource relationType) { + context.define(currentUser, assumedRoles); + + final var entities = relationRbacRepo.findRelationRelatedToPersonUuidAndRelationType(personUuid, + mapper.map(relationType, HsOfficeRelationType.class)); + + final var resources = mapper.mapList(entities, HsOfficeRelationResource.class, + RELATION_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addRelation( + final String currentUser, + final String assumedRoles, + final HsOfficeRelationInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = new HsOfficeRelationRbacEntity(); + entityToSave.setType(HsOfficeRelationType.valueOf(body.getType())); + entityToSave.setMark(body.getMark()); + entityToSave.setAnchor(holderRepo.findByUuid(body.getAnchorUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid()) + )); + entityToSave.setHolder(holderRepo.findByUuid(body.getHolderUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) + )); + entityToSave.setContact(contactrealRepo.findByUuid(body.getContactUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) + )); + + final var saved = relationRbacRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/office/relations/{id}") + .buildAndExpand(saved.getUuid()) + .toUri(); + final var mapped = mapper.map(saved, HsOfficeRelationResource.class, + RELATION_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getRelationByUuid( + final String currentUser, + final String assumedRoles, + final UUID relationUuid) { + + context.define(currentUser, assumedRoles); + + final var result = relationRbacRepo.findByUuid(relationUuid); + if (result.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(mapper.map(result.get(), HsOfficeRelationResource.class, RELATION_ENTITY_TO_RESOURCE_POSTMAPPER)); + } + + @Override + @Transactional + public ResponseEntity deleteRelationByUuid( + final String currentUser, + final String assumedRoles, + final UUID relationUuid) { + context.define(currentUser, assumedRoles); + + final var result = relationRbacRepo.deleteByUuid(relationUuid); + if (result == 0) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchRelation( + final String currentUser, + final String assumedRoles, + final UUID relationUuid, + final HsOfficeRelationPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = relationRbacRepo.findByUuid(relationUuid).orElseThrow(); + + new HsOfficeRelationEntityPatcher(em, current).apply(body); + + final var saved = relationRbacRepo.save(current); + final var mapped = mapper.map(saved, HsOfficeRelationResource.class); + return ResponseEntity.ok(mapped); + } + + + final BiConsumer RELATION_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + resource.setAnchor(mapper.map(entity.getAnchor(), HsOfficePersonResource.class)); + resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class)); + resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationEntityPatcher.java similarity index 60% rename from src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java rename to src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationEntityPatcher.java index fa080ba2..d9e6244a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationEntityPatcher.java @@ -1,28 +1,28 @@ -package net.hostsharing.hsadminng.hs.office.relationship; +package net.hostsharing.hsadminng.hs.office.relation; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.OptionalFromJson; import jakarta.persistence.EntityManager; import java.util.UUID; -class HsOfficeRelationshipEntityPatcher implements EntityPatcher { +class HsOfficeRelationEntityPatcher implements EntityPatcher { private final EntityManager em; - private final HsOfficeRelationshipEntity entity; + private final HsOfficeRelation entity; - HsOfficeRelationshipEntityPatcher(final EntityManager em, final HsOfficeRelationshipEntity entity) { + HsOfficeRelationEntityPatcher(final EntityManager em, final HsOfficeRelation entity) { this.em = em; this.entity = entity; } @Override - public void apply(final HsOfficeRelationshipPatchResource resource) { + public void apply(final HsOfficeRelationPatchResource resource) { OptionalFromJson.of(resource.getContactUuid()).ifPresent(newValue -> { verifyNotNull(newValue, "contact"); - entity.setContact(em.getReference(HsOfficeContactEntity.class, newValue)); + entity.setContact(em.getReference(HsOfficeContactRealEntity.class, newValue)); }); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacEntity.java new file mode 100644 index 00000000..f081404e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacEntity.java @@ -0,0 +1,123 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.errors.DisplayAs; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.io.IOException; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef.inCaseOf; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef.inOtherCases; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.REFERRER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "hs_office_relation_rv") +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder(toBuilder = true) +@DisplayAs("RbacRelation") +public class HsOfficeRelationRbacEntity extends HsOfficeRelation { + + public static RbacView rbac() { + return rbacViewFor("relation", HsOfficeRelationRbacEntity.class) + .withIdentityView(SQL.projection(""" + (select idName from hs_office_person_iv p where p.uuid = anchorUuid) + || '-with-' || target.type || '-' + || (select idName from hs_office_person_iv p where p.uuid = holderUuid) + """)) + .withRestrictedViewOrderBy(SQL.expression( + "(select idName from hs_office_person_iv p where p.uuid = target.holderUuid)")) + .withUpdatableColumns("contactUuid") + .importEntityAlias("anchorPerson", HsOfficePersonEntity.class, usingDefaultCase(), + dependsOnColumn("anchorUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + .importEntityAlias("holderPerson", HsOfficePersonEntity.class, usingDefaultCase(), + dependsOnColumn("holderUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + .importEntityAlias("contact", HsOfficeContactRbacEntity.class, usingDefaultCase(), + dependsOnColumn("contactUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + .switchOnColumn( + "type", + inCaseOf("REPRESENTATIVE", then -> { + then.createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + with.incomingSuperRole("holderPerson", ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.outgoingSubRole("anchorPerson", OWNER); + with.permission(UPDATE); + }) + .createSubRole(AGENT, (with) -> { + with.incomingSuperRole("anchorPerson", ADMIN); + }) + .createSubRole(TENANT, (with) -> { + with.incomingSuperRole("contact", ADMIN); + with.outgoingSubRole("anchorPerson", REFERRER); + with.outgoingSubRole("holderPerson", REFERRER); + with.outgoingSubRole("contact", REFERRER); + with.permission(SELECT); + }); + }), + // inCaseOf("DEBITOR", then -> {}), TODO.spec: needs to be defined + inOtherCases(then -> { + then.createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + with.incomingSuperRole("anchorPerson", ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT, (with) -> { + // TODO.rbac: we need relation:PROXY, to allow changing the relation contact. + // the alternative would be to move this to the relation:ADMIN role, + // but then the partner holder person could update the partner relation itself, + // see partner entity. + with.incomingSuperRole("holderPerson", ADMIN); + }) + .createSubRole(TENANT, (with) -> { + with.incomingSuperRole("contact", ADMIN); + with.outgoingSubRole("anchorPerson", REFERRER); + with.outgoingSubRole("holderPerson", REFERRER); + with.outgoingSubRole("contact", REFERRER); + with.permission(SELECT); + }); + })) + .toRole("anchorPerson", ADMIN).grantPermission(INSERT); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/503-relation/5033-hs-office-relation-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java new file mode 100644 index 00000000..e8187bb7 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java @@ -0,0 +1,37 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeRelationRbacRepository extends Repository { + + Optional findByUuid(UUID id); + + default List findRelationRelatedToPersonUuidAndRelationType(@NotNull UUID personUuid, HsOfficeRelationType relationType) { + return findRelationRelatedToPersonUuidAndRelationTypeString(personUuid, relationType.toString()); + } + + @Query(value = """ + SELECT p.* FROM hs_office_relation_rv AS p + WHERE p.anchorUuid = :personUuid OR p.holderUuid = :personUuid + """, nativeQuery = true) + List findRelationRelatedToPersonUuid(@NotNull UUID personUuid); + + @Query(value = """ + SELECT p.* FROM hs_office_relation_rv AS p + WHERE (:relationType IS NULL OR p.type = cast(:relationType AS HsOfficeRelationType)) + AND ( p.anchorUuid = :personUuid OR p.holderUuid = :personUuid) + """, nativeQuery = true) + List findRelationRelatedToPersonUuidAndRelationTypeString(@NotNull UUID personUuid, String relationType); + + HsOfficeRelationRbacEntity save(final HsOfficeRelationRbacEntity entity); + + long count(); + + int deleteByUuid(UUID uuid); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealEntity.java new file mode 100644 index 00000000..3c6c71a9 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealEntity.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.errors.DisplayAs; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + + +@Entity +@Table(name = "hs_office_relation") +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder(toBuilder = true) +@DisplayAs("RealRelation") +public class HsOfficeRelationRealEntity extends HsOfficeRelation { +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealRepository.java new file mode 100644 index 00000000..6a24ad02 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRealRepository.java @@ -0,0 +1,37 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeRelationRealRepository extends Repository { + + Optional findByUuid(UUID id); + + default List findRelationRelatedToPersonUuidAndRelationType(@NotNull UUID personUuid, HsOfficeRelationType relationType) { + return findRelationRelatedToPersonUuidAndRelationTypeString(personUuid, relationType.toString()); + } + + @Query(value = """ + SELECT p.* FROM hs_office_relation AS p + WHERE p.anchorUuid = :personUuid OR p.holderUuid = :personUuid + """, nativeQuery = true) + List findRelationRelatedToPersonUuid(@NotNull UUID personUuid); + + @Query(value = """ + SELECT p.* FROM hs_office_relation AS p + WHERE (:relationType IS NULL OR p.type = cast(:relationType AS HsOfficeRelationType)) + AND ( p.anchorUuid = :personUuid OR p.holderUuid = :personUuid) + """, nativeQuery = true) + List findRelationRelatedToPersonUuidAndRelationTypeString(@NotNull UUID personUuid, String relationType); + + HsOfficeRelationRealEntity save(final HsOfficeRelationRealEntity entity); + + long count(); + + int deleteByUuid(UUID uuid); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationType.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationType.java new file mode 100644 index 00000000..035c9b55 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationType.java @@ -0,0 +1,12 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +public enum HsOfficeRelationType { + UNKNOWN, + PARTNER, + EX_PARTNER, + REPRESENTATIVE, + VIP_CONTACT, + DEBITOR, + OPERATIONS, + SUBSCRIBER +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java deleted file mode 100644 index 98c6bccf..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java +++ /dev/null @@ -1,152 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationshipsApi; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; -import net.hostsharing.hsadminng.mapper.Mapper; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.UUID; -import java.util.function.BiConsumer; - - -@RestController - -public class HsOfficeRelationshipController implements HsOfficeRelationshipsApi { - - @Autowired - private Context context; - - @Autowired - private Mapper mapper; - - @Autowired - private HsOfficeRelationshipRepository relationshipRepo; - - @Autowired - private HsOfficePersonRepository relHolderRepo; - - @Autowired - private HsOfficeContactRepository contactRepo; - - @PersistenceContext - private EntityManager em; - - @Override - @Transactional(readOnly = true) - public ResponseEntity> listRelationships( - final String currentUser, - final String assumedRoles, - final UUID personUuid, - final HsOfficeRelationshipTypeResource relationshipType) { - context.define(currentUser, assumedRoles); - - final var entities = relationshipRepo.findRelationshipRelatedToPersonUuidAndRelationshipType(personUuid, - mapper.map(relationshipType, HsOfficeRelationshipType.class)); - - final var resources = mapper.mapList(entities, HsOfficeRelationshipResource.class, - RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.ok(resources); - } - - @Override - @Transactional - public ResponseEntity addRelationship( - final String currentUser, - final String assumedRoles, - final HsOfficeRelationshipInsertResource body) { - - context.define(currentUser, assumedRoles); - - final var entityToSave = new HsOfficeRelationshipEntity(); - entityToSave.setRelType(HsOfficeRelationshipType.valueOf(body.getRelType())); - entityToSave.setRelAnchor(relHolderRepo.findByUuid(body.getRelAnchorUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find relAnchorUuid " + body.getRelAnchorUuid()) - )); - entityToSave.setRelHolder(relHolderRepo.findByUuid(body.getRelHolderUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find relHolderUuid " + body.getRelHolderUuid()) - )); - entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find contactUuid " + body.getContactUuid()) - )); - - final var saved = relationshipRepo.save(entityToSave); - - final var uri = - MvcUriComponentsBuilder.fromController(getClass()) - .path("/api/hs/office/relationships/{id}") - .buildAndExpand(saved.getUuid()) - .toUri(); - final var mapped = mapper.map(saved, HsOfficeRelationshipResource.class, - RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.created(uri).body(mapped); - } - - @Override - @Transactional(readOnly = true) - public ResponseEntity getRelationshipByUuid( - final String currentUser, - final String assumedRoles, - final UUID relationshipUuid) { - - context.define(currentUser, assumedRoles); - - final var result = relationshipRepo.findByUuid(relationshipUuid); - if (result.isEmpty()) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(mapper.map(result.get(), HsOfficeRelationshipResource.class, RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER)); - } - - @Override - @Transactional - public ResponseEntity deleteRelationshipByUuid( - final String currentUser, - final String assumedRoles, - final UUID relationshipUuid) { - context.define(currentUser, assumedRoles); - - final var result = relationshipRepo.deleteByUuid(relationshipUuid); - if (result == 0) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.noContent().build(); - } - - @Override - @Transactional - public ResponseEntity patchRelationship( - final String currentUser, - final String assumedRoles, - final UUID relationshipUuid, - final HsOfficeRelationshipPatchResource body) { - - context.define(currentUser, assumedRoles); - - final var current = relationshipRepo.findByUuid(relationshipUuid).orElseThrow(); - - new HsOfficeRelationshipEntityPatcher(em, current).apply(body); - - final var saved = relationshipRepo.save(current); - final var mapped = mapper.map(saved, HsOfficeRelationshipResource.class); - return ResponseEntity.ok(mapped); - } - - - final BiConsumer RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { - resource.setRelAnchor(mapper.map(entity.getRelAnchor(), HsOfficePersonResource.class)); - resource.setRelHolder(mapper.map(entity.getRelHolder(), HsOfficePersonResource.class)); - resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); - }; -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java deleted file mode 100644 index 22cf712a..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -import lombok.*; -import lombok.experimental.FieldNameConstants; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.hsadminng.stringify.Stringify; -import net.hostsharing.hsadminng.stringify.Stringifyable; - -import jakarta.persistence.*; -import java.util.UUID; - -import static net.hostsharing.hsadminng.stringify.Stringify.stringify; - -@Entity -@Table(name = "hs_office_relationship_rv") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@FieldNameConstants -public class HsOfficeRelationshipEntity implements HasUuid, Stringifyable { - - private static Stringify toString = stringify(HsOfficeRelationshipEntity.class, "rel") - .withProp(Fields.relAnchor, HsOfficeRelationshipEntity::getRelAnchor) - .withProp(Fields.relType, HsOfficeRelationshipEntity::getRelType) - .withProp(Fields.relMark, HsOfficeRelationshipEntity::getRelMark) - .withProp(Fields.relHolder, HsOfficeRelationshipEntity::getRelHolder) - .withProp(Fields.contact, HsOfficeRelationshipEntity::getContact); - - private static Stringify toShortString = stringify(HsOfficeRelationshipEntity.class, "rel") - .withProp(Fields.relAnchor, HsOfficeRelationshipEntity::getRelAnchor) - .withProp(Fields.relType, HsOfficeRelationshipEntity::getRelType) - .withProp(Fields.relHolder, HsOfficeRelationshipEntity::getRelHolder); - - @Id - @GeneratedValue - private UUID uuid; - - @ManyToOne - @JoinColumn(name = "relanchoruuid") - private HsOfficePersonEntity relAnchor; - - @ManyToOne - @JoinColumn(name = "relholderuuid") - private HsOfficePersonEntity relHolder; - - @ManyToOne - @JoinColumn(name = "contactuuid") - private HsOfficeContactEntity contact; - - @Column(name = "reltype") - @Enumerated(EnumType.STRING) - private HsOfficeRelationshipType relType; - - @Column(name = "relmark") - private String relMark; - - @Override - public String toString() { - return toString.apply(this); - } - - @Override - public String toShortString() { - return toShortString.apply(this); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java deleted file mode 100644 index d34caa8c..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -import jakarta.validation.constraints.NotNull; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface HsOfficeRelationshipRepository extends Repository { - - Optional findByUuid(UUID id); - - default List findRelationshipRelatedToPersonUuidAndRelationshipType(@NotNull UUID personUuid, HsOfficeRelationshipType relationshipType) { - return findRelationshipRelatedToPersonUuidAndRelationshipTypeString(personUuid, relationshipType.toString()); - } - - @Query(value = """ - SELECT p.* FROM hs_office_relationship_rv AS p - WHERE p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid - """, nativeQuery = true) - List findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid); - - @Query(value = """ - SELECT p.* FROM hs_office_relationship_rv AS p - WHERE (:relationshipType IS NULL OR p.relType = cast(:relationshipType AS HsOfficeRelationshipType)) - AND ( p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid) - """, nativeQuery = true) - List findRelationshipRelatedToPersonUuidAndRelationshipTypeString(@NotNull UUID personUuid, String relationshipType); - - HsOfficeRelationshipEntity save(final HsOfficeRelationshipEntity entity); - - long count(); - - int deleteByUuid(UUID uuid); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java deleted file mode 100644 index 9036adeb..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java +++ /dev/null @@ -1,11 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -public enum HsOfficeRelationshipType { - UNKNOWN, - EX_PARTNER, - REPRESENTATIVE, - VIP_CONTACT, - ACCOUNTING, - OPERATIONS, - SUBSCRIBER -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java index 581cd577..115b8948 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java @@ -14,7 +14,6 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; -import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; @@ -57,7 +56,7 @@ public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi { public ResponseEntity addSepaMandate( final String currentUser, final String assumedRoles, - @Valid final HsOfficeSepaMandateInsertResource body) { + final HsOfficeSepaMandateInsertResource body) { context.define(currentUser, assumedRoles); @@ -132,6 +131,7 @@ public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi { if (entity.getValidity().hasUpperBound()) { resource.setValidTo(entity.getValidity().upper().minusDays(1)); } + resource.getDebitor().setDebitorNumber(entity.getDebitor().getDebitorNumber()); }; final BiConsumer SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntity.java index bdd0b045..a57ee32a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntity.java @@ -1,21 +1,35 @@ package net.hostsharing.hsadminng.hs.office.sepamandate; -import com.vladmihalcea.hibernate.type.range.PostgreSQLRangeType; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType; +import io.hypersistence.utils.hibernate.type.range.Range; import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.errors.DisplayAs; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.Type; import jakarta.persistence.*; +import java.io.IOException; import java.time.LocalDate; import java.util.UUID; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Entity @@ -25,21 +39,23 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor -@DisplayName("SEPA-Mandate") -public class HsOfficeSepaMandateEntity implements Stringifyable, HasUuid { +@DisplayAs("SEPA-Mandate") +public class HsOfficeSepaMandateEntity implements Stringifyable, BaseEntity { private static Stringify stringify = stringify(HsOfficeSepaMandateEntity.class) .withProp(e -> e.getBankAccount().getIban()) .withProp(HsOfficeSepaMandateEntity::getReference) .withProp(HsOfficeSepaMandateEntity::getAgreement) .withProp(e -> e.getValidity().asString()) - .withSeparator(", ") .quotedValues(false); @Id @GeneratedValue private UUID uuid; + @Version + private int version; + @ManyToOne @JoinColumn(name = "debitoruuid") private HsOfficeDebitorEntity debitor; @@ -84,4 +100,53 @@ public class HsOfficeSepaMandateEntity implements Stringifyable, HasUuid { return reference; } + public static RbacView rbac() { + return rbacViewFor("sepaMandate", HsOfficeSepaMandateEntity.class) + .withIdentityView(query(""" + select sm.uuid as uuid, ba.iban || '-' || sm.validity as idName + from hs_office_sepamandate sm + join hs_office_bankaccount ba on ba.uuid = sm.bankAccountUuid + """)) + .withRestrictedViewOrderBy(expression("validity")) + .withUpdatableColumns("reference", "agreement", "validity") + + .importEntityAlias("debitorRel", HsOfficeRelationRbacEntity.class, usingCase(DEBITOR), + dependsOnColumn("debitorUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = ${REF}.debitorUuid + """), + NOT_NULL) + .importEntityAlias("bankAccount", HsOfficeBankAccountEntity.class, usingDefaultCase(), + dependsOnColumn("bankAccountUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT, (with) -> { + with.outgoingSubRole("bankAccount", REFERRER); + with.outgoingSubRole("debitorRel", AGENT); + }) + .createSubRole(REFERRER, (with) -> { + with.incomingSuperRole("bankAccount", ADMIN); + with.incomingSuperRole("debitorRel", AGENT); + with.outgoingSubRole("debitorRel", TENANT); + with.permission(SELECT); + }) + + .toRole("debitorRel", ADMIN).grantPermission(INSERT); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java new file mode 100644 index 00000000..b9f82a87 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java @@ -0,0 +1,67 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Arrays.stream; +import static net.hostsharing.hsadminng.mapper.Array.insertNewEntriesAfterExistingEntry; + +@Setter +public class ArrayProperty

, E> extends ValidatableProperty, E[]> { + + private static final String[] KEY_ORDER = + insertNewEntriesAfterExistingEntry( + insertNewEntriesAfterExistingEntry(ValidatableProperty.KEY_ORDER, "required", "minLength" ,"maxLength"), + "propertyName", "elementsOf"); + private final ValidatableProperty elementsOf; + private Integer minLength; + private Integer maxLength; + + private ArrayProperty(final ValidatableProperty elementsOf) { + //noinspection unchecked + super((Class) elementsOf.type.arrayType(), elementsOf.propertyName, KEY_ORDER); + this.elementsOf = elementsOf; + } + + public static ArrayProperty arrayOf(final ValidatableProperty elementsOf) { + if (elementsOf.type != String.class) { + // see also net.hostsharing.hsadminng.mapper.PatchableMapWrapper.fixValueType + throw new IllegalArgumentException("currently arrayOf(...) is only implemented for stringProperty(...)"); + } + //noinspection unchecked + return (ArrayProperty) new ArrayProperty<>(elementsOf); + } + + public ValidatableProperty minLength(final int minLength) { + this.minLength = minLength; + return self(); + } + + public ValidatableProperty maxLength(final int maxLength) { + this.maxLength = maxLength; + return self(); + } + + @Override + protected void validate(final List result, final E[] propValue, final PropertiesProvider propProvider) { + if (minLength != null && propValue.length < minLength) { + result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + displayArray(propValue) + " is " + propValue.length); + } + if (maxLength != null && propValue.length > maxLength) { + result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + displayArray(propValue) + " is " + propValue.length); + } + stream(propValue).forEach(e -> elementsOf.validate(result, e, propProvider)); + } + + @Override + protected String simpleTypeName() { + return elementsOf.simpleTypeName() + "[]"; + } + + @SafeVarargs + private String displayArray(final E... propValue) { + return "[" + Arrays.toString(propValue) + "]"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java new file mode 100644 index 00000000..abe5f7b4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java @@ -0,0 +1,46 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Setter +public class BooleanProperty extends ValidatableProperty { + + private static final String[] KEY_ORDER = Array.join(ValidatableProperty.KEY_ORDER_HEAD, ValidatableProperty.KEY_ORDER_TAIL); + + private Map.Entry falseIf; + + private BooleanProperty(final String propertyName) { + super(Boolean.class, propertyName, KEY_ORDER); + } + + public static BooleanProperty booleanProperty(final String propertyName) { + return new BooleanProperty(propertyName); + } + + public BooleanProperty falseIf(final String refPropertyName, final String refPropertyValue) { + this.falseIf = new AbstractMap.SimpleImmutableEntry<>(refPropertyName, refPropertyValue); + return this; + } + + @Override + protected void validate(final List result, final Boolean propValue, final PropertiesProvider propProvider) { + if (falseIf != null && propValue) { + final Object referencedValue = propProvider.directProps().get(falseIf.getKey()); + if (Objects.equals(referencedValue, falseIf.getValue())) { + result.add(propertyName + "' is expected to be false because " + + falseIf.getKey() + "=" + referencedValue + " but is " + propValue); + } + } + } + + @Override + protected String simpleTypeName() { + return "boolean"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java new file mode 100644 index 00000000..60e0f244 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java @@ -0,0 +1,63 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Arrays.stream; + +@Setter +public class EnumerationProperty extends ValidatableProperty { + + private static final String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("values"), + ValidatableProperty.KEY_ORDER_TAIL); + + private String[] values; + + private EnumerationProperty(final String propertyName) { + super(String.class, propertyName, KEY_ORDER); + } + + public static EnumerationProperty enumerationProperty(final String propertyName) { + return new EnumerationProperty(propertyName); + } + + public EnumerationProperty values(final String... values) { + this.values = values; + return this; + } + + public void deferredInit(final ValidatableProperty[] allProperties) { + if (hasDeferredInit()) { + if (this.values != null) { + throw new IllegalStateException("property " + this + " already has values"); + } + this.values = doDeferredInit(allProperties); + } + } + + public EnumerationProperty valuesFromProperties(final String propertyNamePrefix) { + this.setDeferredInit( (ValidatableProperty[] allProperties) -> stream(allProperties) + .map(ValidatableProperty::propertyName) + .filter(name -> name.startsWith(propertyNamePrefix)) + .map(name -> name.substring(propertyNamePrefix.length())) + .toArray(String[]::new)); + return this; + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + if (stream(values).noneMatch(v -> v.equals(propValue))) { + result.add(propertyName + "' is expected to be one of " + Arrays.toString(values) + " but is '" + propValue + "'"); + } + } + + @Override + protected String simpleTypeName() { + return "enumeration"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java new file mode 100644 index 00000000..b2fa8a02 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java @@ -0,0 +1,166 @@ +package net.hostsharing.hsadminng.hs.validation; + + + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; +import static net.hostsharing.hsadminng.hs.validation.ValidatableProperty.ComputeMode.IN_INIT; +import static net.hostsharing.hsadminng.hs.validation.ValidatableProperty.ComputeMode.IN_PREP; +import static net.hostsharing.hsadminng.hs.validation.ValidatableProperty.ComputeMode.IN_REVAMP; + +// TODO.refa: rename to HsEntityProcessor, also subclasses +public abstract class HsEntityValidator { + + public static final ThreadLocal localEntityManager = new ThreadLocal<>(); + + public final ValidatableProperty[] propertyValidators; + + public > HsEntityValidator(final ValidatableProperty... validators) { + propertyValidators = validators; + stream(propertyValidators).forEach(p -> p.deferredInit(propertyValidators)); + } + + protected static List enrich(final String prefix, final List messages) { + return messages.stream() + // TODO:refa: this is a bit hacky, I need to find the right place to add the prefix + .map(message -> message.startsWith("'") ? message : ("'" + prefix + "." + message)) + .toList(); + } + + protected static String prefix(final String... parts) { + return String.join(".", parts); + } + + public static R doWithEntityManager(final EntityManager em, final Supplier code) { + localEntityManager.set(em); + try { + return code.get(); + } finally { + localEntityManager.remove(); + } + } + + public abstract List validateEntity(final E entity); + public abstract List validateContext(final E entity); + + public final List> properties() { + return Arrays.stream(propertyValidators) + .map(ValidatableProperty::toOrderedMap) + .toList(); + } + + public final Map> propertiesMap() { + return Arrays.stream(propertyValidators) + .map(ValidatableProperty::toOrderedMap) + .collect(Collectors.toMap(p -> p.get("propertyName").toString(), p -> p)); + } + + /** + Gets called before any validations take place. + Allows to initialize fields and properties to default values. + */ + public void preprocessEntity(final E entity) { + } + + protected ArrayList validateProperties(final PropertiesProvider propsProvider) { + final var result = new ArrayList(); + + // verify that all actually given properties are specified + final var properties = propsProvider.directProps(); + properties.keySet().forEach( givenPropName -> { + if (stream(propertyValidators).map(pv -> pv.propertyName).noneMatch(propName -> propName.equals(givenPropName))) { + result.add(givenPropName + "' is not expected but is set to '" + properties.get(givenPropName) + "'"); + } + }); + + // run all property validators + stream(propertyValidators).forEach(pv -> { + result.addAll(pv.validate(propsProvider)); + }); + + return result; + } + + @SafeVarargs + public static List sequentiallyValidate(final Supplier>... validators) { + return new ArrayList<>(stream(validators) + .map(Supplier::get) + .filter(violations -> !violations.isEmpty()) + .findFirst() + .orElse(emptyList())); + } + + protected static Integer getIntegerValueWithDefault0(final ValidatableProperty prop, final Map propValues) { + final var value = prop.getValue(propValues); + if (value instanceof Integer) { + return (Integer) value; + } + if (value == null) { + return 0; + } + throw new IllegalArgumentException(prop.propertyName + " Integer value expected, but got " + value); + } + + protected static Integer toIntegerWithDefault0(final Object value) { + if (value instanceof Integer) { + return (Integer) value; + } + if (value == null) { + return 0; + } + throw new IllegalArgumentException("Integer value (or null) expected, but got " + value); + } + + public void prepareProperties(final EntityManager em, final E entity) { + stream(propertyValidators).forEach(p -> { + if (p.isComputed(IN_PREP) || p.isComputed(IN_INIT) && !entity.isLoaded() ) { + entity.directProps().put(p.propertyName, p.compute(em, entity)); + } + }); + } + + public Map revampProperties(final EntityManager em, final E entity, final Map config) { + final var copy = new HashMap<>(config); + stream(propertyValidators).forEach(p -> { + if (p.isWriteOnly()) { + copy.remove(p.propertyName); + } else if (p.isComputed(IN_REVAMP)) { + copy.put(p.propertyName, p.compute(em, entity)); + } + }); + return copy; + } + + protected String getPropertyValue(final PropertiesProvider entity, final String propertyName) { + final var rawValue = entity.getDirectValue(propertyName, Object.class); + if (rawValue != null) { + return rawValue.toString(); + } + return Objects.toString(propertiesMap().get(propertyName).get("defaultValue")); + } + + protected String getPropertyValues(final PropertiesProvider entity, final String propertyName) { + final var rawValue = entity.getDirectValue(propertyName, Object[].class); + if (rawValue != null) { + return stream(rawValue).map(Object::toString).collect(Collectors.joining("\n")); + } + return ""; + } + + public ValidatableProperty getProperty(final String propertyName) { + return stream(propertyValidators).filter(pv -> pv.propertyName().equals(propertyName)).findFirst().orElse(null); + } + + public void postPersist(final EntityManager em, final E entity) { + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java new file mode 100644 index 00000000..f61f0d7d --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java @@ -0,0 +1,94 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.lang3.Validate; + +import java.util.List; + +@Setter +public class IntegerProperty

> extends ValidatableProperty { + + private final static String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("unit", "min", "minFrom", "max", "maxFrom", "step"), + ValidatableProperty.KEY_ORDER_TAIL); + + private String unit; + private Integer min; + private String minFrom; + private Integer max; + private String maxFrom; + private Integer factor; + private Integer step; + + public static IntegerProperty integerProperty(final String propertyName) { + return new IntegerProperty<>(propertyName); + } + + private IntegerProperty(final String propertyName) { + super(Integer.class, propertyName, KEY_ORDER); + } + + @Override + public void deferredInit(final ValidatableProperty[] allProperties) { + Validate.isTrue(min == null || minFrom == null, "min and minFrom are exclusive, but both are given"); + Validate.isTrue(max == null || maxFrom == null, "max and maxFrom are exclusive, but both are given"); + } + + public P minFrom(final String propertyName) { + minFrom = propertyName; + return self(); + } + + public P maxFrom(final String propertyName) { + maxFrom = propertyName; + return self(); + } + + public P withFactor(final int factor) { + this.factor = factor; + return self(); + } + + @Override + public String unit() { + return unit; + } + + public Integer max() { + return max; + } + + @Override + protected void validate(final List result, final Integer propValue, final PropertiesProvider propProvider) { + validateMin(result, propertyName, propValue, min); + validateMax(result, propertyName, propValue, max); + if (step != null && propValue % step != 0) { + result.add(propertyName + "' is expected to be multiple of " + step + " but is " + propValue); + } + if (minFrom != null) { + validateMin(result, propertyName, propValue, propProvider.getContextValue(minFrom, Integer.class) * ((factor != null) ? factor : 1)); + } + if (maxFrom != null) { + validateMax(result, propertyName, propValue, propProvider.getContextValue(maxFrom, Integer.class, 0) * ((factor != null) ? factor : 1)); + } + } + + @Override + protected String simpleTypeName() { + return "integer"; + } + + private static void validateMin(final List result, final String propertyName, final Integer propValue, final Integer min) { + if (min != null && propValue < min) { + result.add(propertyName + "' is expected to be at least " + min + " but is " + propValue); + } + } + + private static void validateMax(final List result, final String propertyName, final Integer propValue, final Integer max) { + if (max != null && propValue > max) { + result.add(propertyName + "' is expected to be at most " + max + " but is " + propValue); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java new file mode 100644 index 00000000..ceaf2603 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java @@ -0,0 +1,91 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hash.HashGenerator.Algorithm; + +import java.util.List; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.mapper.Array.insertNewEntriesAfterExistingEntry; + +@Setter +public class PasswordProperty extends StringProperty { + + private static final String[] KEY_ORDER = insertNewEntriesAfterExistingEntry( + StringProperty.KEY_ORDER, + "computed", + "hashedUsing"); + + private Algorithm hashedUsing; + + private PasswordProperty(final String propertyName) { + super(propertyName, KEY_ORDER); + undisclosed(); + } + + public static PasswordProperty passwordProperty(final String propertyName) { + return new PasswordProperty(propertyName); + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + // TODO.impl: remove after legacy data is migrated + if (HashGenerator.using(hashedUsing).couldBeHash(propValue) && propValue.length() > this.maxLength()) { + // already hashed => do not validate + return; + } + + super.validate(result, propValue, propProvider); + validatePassword(result, propValue); + } + + public PasswordProperty hashedUsing(final Algorithm algorithm) { + this.hashedUsing = algorithm; + computedBy( + ComputeMode.IN_PREP, + (em, entity) -> ofNullable(entity.getDirectValue(propertyName, String.class)) + .map(password -> HashGenerator.using(algorithm).withRandomSalt().hashIfNotYetHashed(password)) + .orElse(null)); + return self(); + } + + @Override + protected String simpleTypeName() { + return "password"; + } + + private void validatePassword(final List result, final String password) { + boolean hasLowerCase = false; + boolean hasUpperCase = false; + boolean hasDigit = false; + boolean hasSpecialChar = false; + boolean containsColon = false; + + for (char c : password.toCharArray()) { + if (Character.isLowerCase(c)) { + hasLowerCase = true; + } else if (Character.isUpperCase(c)) { + hasUpperCase = true; + } else if (Character.isDigit(c)) { + hasDigit = true; + } else if (!Character.isLetterOrDigit(c)) { + hasSpecialChar = true; + } + + if (c == ':') { + containsColon = true; + } + } + + final long groupsCovered = Stream.of(hasLowerCase, hasUpperCase, hasDigit, hasSpecialChar).filter(v -> v).count(); + if (groupsCovered < 3) { + result.add(propertyName + + "' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters"); + } + if (containsColon) { + result.add(propertyName + "' must not contain colon (':')"); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java new file mode 100644 index 00000000..89c3f5cd --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java @@ -0,0 +1,40 @@ +package net.hostsharing.hsadminng.hs.validation; + +import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; + +public interface PropertiesProvider { + + boolean isLoaded(); + PatchableMapWrapper directProps(); + Object getContextValue(final String propName); + + default T getDirectValue(final String propName, final Class clazz) { + return cast(propName, directProps().get(propName), clazz, null); + } + + default T getDirectValue(final String propName, final Class clazz, final T defaultValue) { + return cast(propName, directProps().get(propName), clazz, defaultValue); + } + + default boolean isPatched(String propertyName) { + return directProps().isPatched(propertyName); + } + + default T getContextValue(final String propName, final Class clazz) { + return cast(propName, getContextValue(propName), clazz, null); + } + + default T getContextValue(final String propName, final Class clazz, final T defaultValue) { + return cast(propName, getContextValue(propName), clazz, defaultValue); + } + + private static T cast( final String propName, final Object value, final Class clazz, final T defaultValue) { + if (value == null && defaultValue != null) { + return defaultValue; + } + if (value == null || clazz.isInstance(value)) { + return clazz.cast(value); + } + throw new IllegalStateException(propName + " expected to be an "+clazz.getSimpleName()+", but got '" + value + "'"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java new file mode 100644 index 00000000..6dc463d6 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java @@ -0,0 +1,162 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.AccessLevel; +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; + +@Setter +public class StringProperty

> extends ValidatableProperty { + + protected static final String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("matchesRegEx", "matchesRegExDescription", + "notMatchesRegEx", "notMatchesRegExDescription", + "minLength", "maxLength", + "provided"), + ValidatableProperty.KEY_ORDER_TAIL, + Array.of("undisclosed")); + private String[] provided; + private Pattern[] matchesRegEx; + private String matchesRegExDescription; + private Pattern[] notMatchesRegEx; + private String notMatchesRegExDescription; + @Setter(AccessLevel.PRIVATE) + private Consumer describedAsConsumer; + private Integer minLength; + private Integer maxLength; + private boolean undisclosed; + + protected StringProperty(final String propertyName) { + super(String.class, propertyName, KEY_ORDER); + } + + protected StringProperty(final String propertyName, final String[] keyOrder) { + super(String.class, propertyName, keyOrder); + } + + public static StringProperty stringProperty(final String propertyName) { + return new StringProperty<>(propertyName); + } + + public P minLength(final int minLength) { + this.minLength = minLength; + return self(); + } + + public Integer minLength() { + return this.minLength; + } + + public P maxLength(final int maxLength) { + this.maxLength = maxLength; + return self(); + } + + public Integer maxLength() { + return this.maxLength; + } + + public P matchesRegEx(final String... regExPattern) { + this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new); + this.describedAsConsumer = violationMessage -> matchesRegExDescription = violationMessage; + return self(); + } + + public P notMatchesRegEx(final String... regExPattern) { + this.notMatchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new); + this.describedAsConsumer = violationMessage -> notMatchesRegExDescription = violationMessage; + return self(); + } + + public P describedAs(final String violationMessage) { + describedAsConsumer.accept(violationMessage); + describedAsConsumer = null; + return self(); + } + + /// predefined values, similar to fixed values in a combobox + public P provided(final String... provided) { + this.provided = provided; + return self(); + } + + /** + * The property value is not disclosed in error messages. + * + * @return this; + */ + public P undisclosed() { + this.undisclosed = true; + return self(); + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + super.validate(result, propValue, propProvider); + validateMinLength(result, propValue); + validateMaxLength(result, propValue); + validateMatchesRegEx(result, propValue); + validateNotMatchesRegEx(result, propValue); + } + + @Override + protected String display(final String propValue) { + return undisclosed ? "provided value" : ("'" + propValue + "'"); + } + + @Override + protected String simpleTypeName() { + return "string"; + } + + private void validateMinLength(final List result, final String propValue) { + if (minLength != null && propValue.length() result, final String propValue) { + if (maxLength != null && propValue.length()>maxLength) { + result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length()); + } + } + + private void validateMatchesRegEx(final List result, final String propValue) { + if (matchesRegEx != null && + stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) { + if (matchesRegExDescription != null) { + result.add(propertyName + "' = " + display(propValue) + " " + matchesRegExDescription); + } else if (matchesRegEx.length>1) { + result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + + " but " + display(propValue) + " does not match any"); + } else { + result.add(propertyName + "' is expected to match " + Arrays.toString(matchesRegEx) + " but " + display( + propValue) + + " does not match"); + } + } + } + + private void validateNotMatchesRegEx(final List result, final String propValue) { + if (notMatchesRegEx != null && + stream(notMatchesRegEx).map(p -> p.matcher(propValue)).anyMatch(Matcher::matches)) { + if (notMatchesRegExDescription != null) { + result.add(propertyName + "' = " + display(propValue) + " " + notMatchesRegExDescription); + } else if (notMatchesRegEx.length>1) { + result.add(propertyName + "' is expected not to match any of " + Arrays.toString(notMatchesRegEx) + + " but " + display(propValue) + " does match at least one"); + } else { + result.add(propertyName + "' is expected not to match " + Arrays.toString(notMatchesRegEx) + + " but " + display(propValue) + " does match"); + } + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java new file mode 100644 index 00000000..fb51e7fe --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java @@ -0,0 +1,363 @@ +package net.hostsharing.hsadminng.hs.validation; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.experimental.Accessors; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.lang3.function.TriFunction; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; +import static org.apache.commons.lang3.ObjectUtils.isArray; + +@Getter +@RequiredArgsConstructor +public abstract class ValidatableProperty

, T> { + + protected static final String[] KEY_ORDER_HEAD = Array.of("propertyName"); + protected static final String[] KEY_ORDER_TAIL = Array.of("required", "requiresAtLeastOneOf", "requiresAtMaxOneOf", "defaultValue", "readOnly", "writeOnce","writeOnly", "computed", "isTotalsValidator", "thresholdPercentage"); + protected static final String[] KEY_ORDER = Array.join(KEY_ORDER_HEAD, KEY_ORDER_TAIL); + + final Class type; + final String propertyName; + + @JsonIgnore + private final String[] keyOrder; + + private Boolean required; + private Set requiresAtLeastOneOf; + private Set requiresAtMaxOneOf; + private T defaultValue; + + protected enum ComputeMode { + IN_INIT, + IN_PREP, + IN_REVAMP + } + + @JsonIgnore + private BiFunction computedBy; + + @Accessors(makeFinal = true, chain = true, fluent = false) + private ComputeMode computed; // name 'computed' instead 'computeMode' for better readability in property description + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean readOnly; + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean writeOnly; + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean writeOnce; + + private Function[], T[]> deferredInit; + private boolean isTotalsValidator = false; + + @JsonIgnore + private List>> asTotalLimitValidators; // TODO.impl: move to BookingItemIntegerProperty + + private Integer thresholdPercentage; // TODO.impl: move to IntegerProperty + + public final P self() { + //noinspection unchecked + return (P) this; + } + + public String unit() { + return null; + } + + protected void setDeferredInit(final Function[], T[]> function) { + this.deferredInit = function; + } + + public boolean hasDeferredInit() { + return deferredInit != null; + } + + public T[] doDeferredInit(final ValidatableProperty[] allProperties) { + return deferredInit.apply(allProperties); + } + + public P writeOnly() { + this.writeOnly = true; + return self(); + } + + public P writeOnce() { + this.writeOnce = true; + return self(); + } + + public P readOnly() { + this.readOnly = true; + return self(); + } + + public P required() { + required = TRUE; + return self(); + } + + public P optional() { + required = FALSE; + return self(); + } + + public P requiresAtLeastOneOf(final String... propNames) { + requiresAtLeastOneOf = new LinkedHashSet<>(List.of(propNames)); + return self(); + } + + public P requiresAtMaxOneOf(final String... propNames) { + requiresAtMaxOneOf = new LinkedHashSet<>(List.of(propNames)); + return self(); + } + + public P withDefault(final T value) { + defaultValue = value; + required = FALSE; + return self(); + } + + public void deferredInit(final ValidatableProperty[] allProperties) { + } + + public P asTotalLimit() { + isTotalsValidator = true; + return self(); + } + + public P asTotalLimitFor(final String propertyName, final String propertyValue) { + if (asTotalLimitValidators == null) { + asTotalLimitValidators = new ArrayList<>(); + } + final TriFunction, Integer, List> validator = + (final HsBookingItem entity, final IntegerProperty prop, final Integer factor) -> { + + final var total = entity.getSubBookingItems().stream() + .map(server -> server.getResources().get(propertyName)) + .filter(propertyValue::equals) + .count(); + + final long limitingValue = ofNullable(prop.getValue(entity.getResources())).orElse(0); + if (total > factor*limitingValue) { + return List.of( + prop.propertyName() + " maximum total is " + (factor*limitingValue) + ", but actual total for " + propertyName + "=" + propertyValue + " is " + total + ); + } + return emptyList(); + }; + asTotalLimitValidators.add((final HsBookingItem entity) -> validator.apply(entity, (IntegerProperty)this, 1)); + return self(); + } + + public String propertyName() { + return propertyName; + } + + public boolean isTotalsValidator() { + return isTotalsValidator || asTotalLimitValidators != null; + } + + public Integer thresholdPercentage() { + return thresholdPercentage; + } + + public ValidatableProperty eachComprising(final int factor, final TriFunction, Integer, List> validator) { + if (asTotalLimitValidators == null) { + asTotalLimitValidators = new ArrayList<>(); + } + asTotalLimitValidators.add((final HsBookingItem entity) -> validator.apply(entity, (IntegerProperty)this, factor)); + return this; + } + + public P withThreshold(final Integer percentage) { + this.thresholdPercentage = percentage; + return self(); + } + + public final List validate(final PropertiesProvider propsProvider) { + final var result = new ArrayList(); + final var props = propsProvider.directProps(); + final var propValue = props.get(propertyName); + + if (propValue == null) { + if (required == TRUE) { + result.add(propertyName + "' is required but missing"); + } + if (isWriteOnce() && propsProvider.isLoaded() && propsProvider.isPatched(propertyName) ) { + result.add(propertyName + "' is write-once but got removed"); + } + validateRequiresAtLeastOneOf(result, propsProvider); + } + if (propValue != null){ + validateRequiresAtMaxOneOf(result, propsProvider); + + if ( type.isInstance(propValue)) { + //noinspection unchecked + validate(result, (T) propValue, propsProvider); + } else { + result.add(propertyName + "' is expected to be of type " + type.getSimpleName() + ", " + + "but is of type " + propValue.getClass().getSimpleName()); + } + } + return result; + } + + private void validateRequiresAtLeastOneOf(final ArrayList result, final PropertiesProvider propsProvider) { + if (requiresAtLeastOneOf != null ) { + final var allPropNames = propsProvider.directProps().keySet(); + final var entriesWithValue = allPropNames.stream() + .filter(name -> requiresAtLeastOneOf.contains(name)) + .count(); + if (entriesWithValue == 0) { + result.add(propertyName + "' is required once in group " + requiresAtLeastOneOf + " but missing"); + } + } + } + + private void validateRequiresAtMaxOneOf(final ArrayList result, final PropertiesProvider propsProvider) { + if (requiresAtMaxOneOf != null) { + final var allPropNames = propsProvider.directProps().keySet(); + final var entriesWithValue = allPropNames.stream() + .filter(name -> requiresAtMaxOneOf.contains(name)) + .count(); + if (entriesWithValue > 1) { + result.add(propertyName + "' is required at max once in group " + requiresAtMaxOneOf + + " but multiple properties are set"); + } + } + } + + protected void validate(final List result, final T propValue, final PropertiesProvider propProvider) { + if (isReadOnly() && propValue != null) { + result.add(propertyName + "' is readonly but given as " + display(propValue)); + } + if (isWriteOnce() && propProvider.isLoaded() && propValue != null && propProvider.isPatched(propertyName) ) { + result.add(propertyName + "' is write-once but given as " + display(propValue)); + } + } + + public void verifyConsistency(final Map.Entry, ?> typeDef) { + if (isSpecPotentiallyComplete()) { + throw new IllegalStateException(typeDef.getKey() + "[" + propertyName + "] not fully initialized, please call either .readOnly(), .required(), .optional(), .withDefault(...), .requiresAtLeastOneOf(...) or .requiresAtMaxOneOf(...)" ); + } + } + + private boolean isSpecPotentiallyComplete() { + return required == null && requiresAtLeastOneOf == null && requiresAtMaxOneOf == null && !readOnly && !writeOnly + && defaultValue == null && computedBy == null; + } + + @SuppressWarnings("unchecked") + public T getValue(final Map propValues) { + return (T) Optional.ofNullable(propValues.get(propertyName)).orElse(defaultValue); + } + + protected String display(final T propValue) { + return propValue == null ? null : propValue.toString(); + } + + protected abstract String simpleTypeName(); + + public Map toOrderedMap() { + Map sortedMap = new LinkedHashMap<>(); + sortedMap.put("type", simpleTypeName()); + + // Add entries according to the given order + for (String key : keyOrder) { + final Optional propValue = getPropertyValue(key); + propValue.filter(ValidatableProperty::isToBeRendered).ifPresent(o -> sortedMap.put(key, o)); + } + + return sortedMap; + } + + private static boolean isToBeRendered(final Object v) { + return !(v instanceof Boolean b) || b; + } + + @SneakyThrows + private Optional getPropertyValue(final String key) { + return getPropertyValue(getClass(), key); + } + + @SneakyThrows + private Optional getPropertyValue(final Class clazz, final String key) { + try { + final var field = clazz.getDeclaredField(key); + field.setAccessible(true); + return Optional.ofNullable(arrayToList(field.get(this))); + } catch (final NoSuchFieldException exc) { + if (clazz.getSuperclass() != null) { + return getPropertyValue(clazz.getSuperclass(), key); + } + throw exc; + } + } + + private Object arrayToList(final Object value) { + if (isArray(value)) { + return Arrays.stream((Object[])value).map(Object::toString).toList(); + } + return value; + } + + public List validateTotals(final HsBookingItem bookingItem) { + if (asTotalLimitValidators==null) { + return emptyList(); + } + return asTotalLimitValidators.stream() + .map(v -> v.apply(bookingItem)) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .toList(); + } + + public P initializedBy(final BiFunction compute) { + return computedBy(ComputeMode.IN_INIT, compute); + } + + public P renderedBy(final BiFunction compute) { + return computedBy(ComputeMode.IN_REVAMP, compute); + } + + protected P computedBy(final ComputeMode computeMode, final BiFunction compute) { + this.computedBy = compute; + this.computed = computeMode; + return self(); + } + + public boolean isComputed(final ComputeMode computeMode) { + return computed == computeMode; + } + + public T compute(final EntityManager em, final E entity) { + return computedBy.apply(em, entity); + } + + @Override + public String toString() { + return toOrderedMap().toString(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/lombok.config b/src/main/java/net/hostsharing/hsadminng/hs/validation/lombok.config new file mode 100644 index 00000000..18183936 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/lombok.config @@ -0,0 +1,3 @@ +lombok.addLombokGeneratedAnnotation = true +lombok.accessors.chain = true +lombok.accessors.fluent = true diff --git a/src/test/java/net/hostsharing/test/Array.java b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java similarity index 50% rename from src/test/java/net/hostsharing/test/Array.java rename to src/main/java/net/hostsharing/hsadminng/mapper/Array.java index 87fa92ff..7f3e32d0 100644 --- a/src/test/java/net/hostsharing/test/Array.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java @@ -1,10 +1,14 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.mapper; + + import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; +import static java.util.Arrays.asList; + /** * Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter, * but no Array.of(...). Here it is. @@ -37,4 +41,35 @@ public class Array { return resultList.toArray(String[]::new); } + public static String[] join(final String[]... parts) { + final String[] joined = Arrays.stream(parts) + .flatMap(Arrays::stream) + .toArray(String[]::new); + return joined; + } + + public static T[] emptyArray() { + return of(); + } + + public static T[] emptyArray(final Class elementClass) { + //noinspection unchecked + return (T[]) java.lang.reflect.Array.newInstance(elementClass, 0); + } + + @SafeVarargs + public static T[] insertNewEntriesAfterExistingEntry(final T[] array, final T entryToFind, final T... newEntries) { + final var arrayList = new ArrayList<>(asList(array)); + final var index = arrayList.indexOf(entryToFind); + if (index < 0) { + throw new IllegalArgumentException("entry "+ entryToFind + " not found in " + Arrays.toString(array)); + } + for (int n = 0; n < newEntries.length; ++n) { + arrayList.add(index +n + 1, newEntries[n]); + } + + @SuppressWarnings("unchecked") + final var extendedArray = (T[]) java.lang.reflect.Array.newInstance(array.getClass().getComponentType(), array.length); + return arrayList.toArray(extendedArray); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/KeyValueMap.java b/src/main/java/net/hostsharing/hsadminng/mapper/KeyValueMap.java new file mode 100644 index 00000000..7fded816 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/mapper/KeyValueMap.java @@ -0,0 +1,14 @@ +package net.hostsharing.hsadminng.mapper; + +import java.util.Map; + +public class KeyValueMap { + + @SuppressWarnings("unchecked") + public static Map from(final Object obj) { + if (obj == null || obj instanceof Map) { + return (Map) obj; + } + throw new ClassCastException("Map expected, but got: " + obj); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java b/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java index 642a36b9..9fda5165 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/Mapper.java @@ -1,6 +1,5 @@ package net.hostsharing.hsadminng.mapper; -import net.hostsharing.hsadminng.errors.DisplayName; import org.modelmapper.ModelMapper; import org.springframework.util.ReflectionUtils; @@ -13,6 +12,8 @@ import java.util.List; import java.util.function.BiConsumer; import java.util.stream.Collectors; +import static net.hostsharing.hsadminng.errors.DisplayAs.DisplayName; + /** * A nicer API for ModelMapper. */ @@ -74,11 +75,7 @@ public class Mapper extends ModelMapper { if (entity != null) { return entity; } - final var displayNameAnnot = entityClass.getAnnotation(DisplayName.class); - final var displayName = displayNameAnnot != null ? displayNameAnnot.value() : entityClass.getSimpleName(); - throw new ValidationException("Unable to find %s with uuid %s".formatted( - displayName, subEntityUuid - )); + throw new ValidationException("Unable to find " + DisplayName.of(entityClass) + " by uuid: " + subEntityUuid); } public T map(final S source, final Class targetClass, final BiConsumer postMapper) { diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PatchMap.java b/src/main/java/net/hostsharing/hsadminng/mapper/PatchMap.java new file mode 100644 index 00000000..6fd843c9 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/mapper/PatchMap.java @@ -0,0 +1,30 @@ +package net.hostsharing.hsadminng.mapper; + +import org.apache.commons.lang3.tuple.ImmutablePair; + +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import java.util.TreeMap; + +import static java.util.Arrays.stream; + +/** + * This is a map which can take key-value-pairs where the value can be null + * thus JSON nullable object structures from HTTP PATCH can be represented. + */ +public class PatchMap extends TreeMap { + + public PatchMap(final ImmutablePair[] entries) { + stream(entries).forEach(r -> put(r.getKey(), r.getValue())); + } + + @SafeVarargs + public static Map patchMap(final ImmutablePair... entries) { + return new PatchMap(entries); + } + + @NotNull + public static ImmutablePair entry(final String key, final T value) { + return new ImmutablePair<>(key, value); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java new file mode 100644 index 00000000..a81d6739 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java @@ -0,0 +1,161 @@ +package net.hostsharing.hsadminng.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.SneakyThrows; +import org.apache.commons.lang3.tuple.ImmutablePair; + +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import static java.util.Optional.ofNullable; + +/** This class wraps another (usually persistent) map and + * supports applying `PatchMap` as well as a toString method with stable entry order. + */ +public class PatchableMapWrapper implements Map { + + private static final ObjectMapper jsonWriter = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(SerializationFeature.INDENT_OUTPUT, true); + + private final Map delegate; + private final Set patched = new HashSet<>(); + + private PatchableMapWrapper(final Map map) { + delegate = map; + } + + public static PatchableMapWrapper of(final PatchableMapWrapper currentWrapper, final Consumer> setWrapper, final Map target) { + return ofNullable(currentWrapper).orElseGet(() -> { + final var newWrapper = new PatchableMapWrapper(target); + setWrapper.accept(newWrapper); + return newWrapper; + }); + } + + public static PatchableMapWrapper of(final Map delegate) { + return new PatchableMapWrapper(delegate); + } + + @NotNull + public static ImmutablePair entry(final String key, final E value) { + return new ImmutablePair<>(key, value); + } + + public void assign(final Map entries) { + if (entries != null ) { + delegate.clear(); + delegate.putAll(entries); + patched.clear(); + } + } + + public void patch(final Map patch) { + patch.forEach((key, value) -> { + if (value == null) { + remove(key); + } else { + put(key, value); + } + }); + } + + public boolean isPatched(final String propertyName) { + return patched.contains(propertyName); + } + + @SneakyThrows + public String toString() { + return jsonWriter.writeValueAsString(delegate); + } + + // --- below just delegating methods -------------------------------- + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public boolean containsKey(final Object key) { + return delegate.containsKey(key); + } + + @Override + public boolean containsValue(final Object value) { + return delegate.containsValue(value); + } + + @Override + public T get(final Object key) { + return delegate.get(key); + } + + @Override + public T put(final String key, final T value) { + if (!Objects.equals(value, delegate.get(key))) { + patched.add(key); + } + return delegate.put(key, fixValueType(value)); + } + + @Override + public T remove(final Object key) { + if (delegate.containsKey(key.toString())) { + patched.add(key.toString()); + } + return delegate.remove(key); + } + + @Override + public void putAll(final @NotNull Map m) { + delegate.putAll(m); + } + + @Override + public void clear() { + patched.addAll(delegate.keySet()); + delegate.clear(); + } + + @Override + @NotNull + public Set keySet() { + return delegate.keySet(); + } + + @Override + @NotNull + public Collection values() { + return delegate.values(); + } + + @Override + @NotNull + public Set> entrySet() { + return delegate.entrySet(); + } + + private T fixValueType(final T value) { + if (value instanceof ArrayList arrayListValue) { + // Jackson deserialization creates ArrayList for JSON arrays, but we need a String[]. + // Jackson could be configured to create Object[], but that does not help. + final var valueToPut = arrayListValue.stream().map(Object::toString).toArray(String[]::new); + //noinspection unchecked + return (T) valueToPut; + } + return value; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PostgresArray.java b/src/main/java/net/hostsharing/hsadminng/mapper/PostgresArray.java deleted file mode 100644 index e1e1d056..00000000 --- a/src/main/java/net/hostsharing/hsadminng/mapper/PostgresArray.java +++ /dev/null @@ -1,58 +0,0 @@ -package net.hostsharing.hsadminng.mapper; - -import lombok.experimental.UtilityClass; -import org.postgresql.util.PGtokenizer; - -import java.lang.reflect.Array; -import java.nio.charset.StandardCharsets; -import java.util.function.Function; - -@UtilityClass -public class PostgresArray { - - /** - * Converts a byte[], as returned for a Postgres-array by native queries, to a Java array. - * - *

This example code worked with Hibernate 5 (Spring Boot 3.0.x): - *


-     *      return (UUID[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class).getSingleResult();
-     * 
- *

- * - *

With Hibernate 6 (Spring Boot 3.1.x), this utility method can be used like such: - *


-     *      final byte[] result = (byte[]) em.createNativeQuery("select * from currentSubjectsUuids() as uuids", UUID[].class)
-     *                 .getSingleResult();
-     *      return fromPostgresArray(result, UUID.class, UUID::fromString);
-     * 
- *

- * - * @param pgArray the byte[] returned by a native query containing as rendered for a Postgres array - * @param elementClass the class of a single element of the Java array to be returned - * @param itemParser converts a string element to the specified elementClass - * @return a Java array containing the data from pgArray - * @param type of a single element of the Java array - */ - public static T[] fromPostgresArray(final byte[] pgArray, final Class elementClass, final Function itemParser) { - final var pgArrayLiteral = new String(pgArray, StandardCharsets.UTF_8); - if (pgArrayLiteral.length() == 2) { - return newGenericArray(elementClass, 0); - } - final PGtokenizer tokenizer = new PGtokenizer(pgArrayLiteral.substring(1, pgArrayLiteral.length()-1), ','); - tokenizer.remove("\"", "\""); - final T[] array = newGenericArray(elementClass, tokenizer.getSize()); // Create a new array of the specified type and length - for ( int n = 0; n < tokenizer.getSize(); ++n ) { - final String token = tokenizer.getToken(n); - if ( !"NULL".equals(token) ) { - array[n] = itemParser.apply(token.trim().replace("\\\"", "\"")); - } - } - return array; - } - - @SuppressWarnings("unchecked") - private static T[] newGenericArray(final Class elementClass, final int length) { - return (T[]) Array.newInstance(elementClass, length); - } - -} diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PostgresDateRange.java b/src/main/java/net/hostsharing/hsadminng/mapper/PostgresDateRange.java index c360db1a..db6ad189 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/PostgresDateRange.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/PostgresDateRange.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.mapper; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import lombok.experimental.UtilityClass; import java.time.LocalDate; diff --git a/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java b/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java new file mode 100644 index 00000000..309986bc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapper.java @@ -0,0 +1,300 @@ +package net.hostsharing.hsadminng.persistence; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.FlushModeType; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import jakarta.persistence.StoredProcedureQuery; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.CriteriaUpdate; +import jakarta.persistence.metamodel.Metamodel; +import java.util.List; +import java.util.Map; + +/** A Spring bean wrapper for the EntityManager. + * + *

@PersistenceContext cannot be properly mocked in @WebMvcTest-based tests + * because Spring will always create a proxy for the mock which then fails because it has no active transaction.

+ * + *

Also, @PersistenceContext cannot be used for constructor injection, though a bean factory would solve that problem.

+ * + *

Use this wrapper **only** if needed for a @WebMvcTest with a mocked EntityManager, otherwise use the original EntityManager.

+ */ +@Component +@NoArgsConstructor +@AllArgsConstructor +public class EntityManagerWrapper implements EntityManager { + + @PersistenceContext + EntityManager em; + + @Override + public void persist(final Object entity) { + em.persist(entity); + } + + @Override + public T merge(final T entity) { + return em.merge(entity); + } + + @Override + public void remove(final Object entity) { + em.remove(entity); + } + + @Override + public T find(final Class entityClass, final Object primaryKey) { + return em.find(entityClass, primaryKey); + } + + @Override + public T find(final Class entityClass, final Object primaryKey, final Map properties) { + return em.find(entityClass, primaryKey, properties); + } + + @Override + public T find(final Class entityClass, final Object primaryKey, final LockModeType lockMode) { + return em.find(entityClass, primaryKey, lockMode); + } + + @Override + public T find( + final Class entityClass, + final Object primaryKey, + final LockModeType lockMode, + final Map properties) { + return em.find(entityClass, primaryKey, lockMode, properties); + } + + @Override + public T getReference(final Class entityClass, final Object primaryKey) { + return em.getReference(entityClass, primaryKey); + } + + @Override + public void flush() { + em.flush(); + } + + @Override + public void setFlushMode(final FlushModeType flushMode) { + em.setFlushMode(flushMode); + } + + @Override + public FlushModeType getFlushMode() { + return em.getFlushMode(); + } + + @Override + public void lock(final Object entity, final LockModeType lockMode) { + em.lock(entity, lockMode); + } + + @Override + public void lock(final Object entity, final LockModeType lockMode, final Map properties) { + em.lock(entity, lockMode, properties); + } + + @Override + public void refresh(final Object entity) { + em.refresh(entity); + } + + @Override + public void refresh(final Object entity, final Map properties) { + em.refresh(entity, properties); + } + + @Override + public void refresh(final Object entity, final LockModeType lockMode) { + em.refresh(entity, lockMode); + } + + @Override + public void refresh(final Object entity, final LockModeType lockMode, final Map properties) { + em.refresh(entity, lockMode, properties); + } + + @Override + public void clear() { + em.clear(); + } + + @Override + public void detach(final Object entity) { + em.detach(entity); + } + + @Override + public boolean contains(final Object entity) { + return em.contains(entity); + } + + @Override + public LockModeType getLockMode(final Object entity) { + return em.getLockMode(entity); + } + + @Override + public void setProperty(final String propertyName, final Object value) { + em.setProperty(propertyName, value); + } + + @Override + public Map getProperties() { + return em.getProperties(); + } + + @Override + public Query createQuery(final String qlString) { + return em.createQuery(qlString); + } + + @Override + public TypedQuery createQuery(final CriteriaQuery criteriaQuery) { + return em.createQuery(criteriaQuery); + } + + @Override + public Query createQuery(final CriteriaUpdate updateQuery) { + return em.createQuery(updateQuery); + } + + @Override + public Query createQuery(final CriteriaDelete deleteQuery) { + return em.createQuery(deleteQuery); + } + + @Override + public TypedQuery createQuery(final String qlString, final Class resultClass) { + return em.createQuery(qlString, resultClass); + } + + @Override + public Query createNamedQuery(final String name) { + return em.createNamedQuery(name); + } + + @Override + public TypedQuery createNamedQuery(final String name, final Class resultClass) { + return em.createNamedQuery(name, resultClass); + } + + @Override + public Query createNativeQuery(final String sqlString) { + return em.createNativeQuery(sqlString); + } + + @Override + public Query createNativeQuery(final String sqlString, final Class resultClass) { + return em.createNativeQuery(sqlString, resultClass); + } + + @Override + public Query createNativeQuery(final String sqlString, final String resultSetMapping) { + return em.createNativeQuery(sqlString, resultSetMapping); + } + + @Override + public StoredProcedureQuery createNamedStoredProcedureQuery(final String name) { + return em.createNamedStoredProcedureQuery(name); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName) { + return em.createStoredProcedureQuery(procedureName); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName, final Class... resultClasses) { + return em.createStoredProcedureQuery(procedureName, resultClasses); + } + + @Override + public StoredProcedureQuery createStoredProcedureQuery(final String procedureName, final String... resultSetMappings) { + return em.createStoredProcedureQuery(procedureName, resultSetMappings); + } + + @Override + public void joinTransaction() { + em.joinTransaction(); + } + + @Override + public boolean isJoinedToTransaction() { + return em.isJoinedToTransaction(); + } + + @Override + public T unwrap(final Class cls) { + return em.unwrap(cls); + } + + @Override + public Object getDelegate() { + return em.getDelegate(); + } + + @Override + public void close() { + em.close(); + } + + @Override + public boolean isOpen() { + return em.isOpen(); + } + + @Override + public EntityTransaction getTransaction() { + return em.getTransaction(); + } + + @Override + public EntityManagerFactory getEntityManagerFactory() { + return em.getEntityManagerFactory(); + } + + @Override + public CriteriaBuilder getCriteriaBuilder() { + return em.getCriteriaBuilder(); + } + + @Override + public Metamodel getMetamodel() { + return em.getMetamodel(); + } + + @Override + public EntityGraph createEntityGraph(final Class rootType) { + return em.createEntityGraph(rootType); + } + + @Override + public EntityGraph createEntityGraph(final String graphName) { + return em.createEntityGraph(graphName); + } + + @Override + public EntityGraph getEntityGraph(final String graphName) { + return em.getEntityGraph(graphName); + } + + @Override + public List> getEntityGraphs(final Class entityClass) { + return em.getEntityGraphs(entityClass); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java new file mode 100644 index 00000000..7c8b08ea --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java @@ -0,0 +1,326 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import java.util.Optional; +import java.util.Set; +import java.util.function.BinaryOperator; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.NEW; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.PERM_TO_ROLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.GUEST; +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; +import static org.apache.commons.lang3.StringUtils.capitalize; +import static org.apache.commons.lang3.StringUtils.uncapitalize; + +public class InsertTriggerGenerator { + + private final RbacView rbacDef; + private final String liquibaseTagPrefix; + + public InsertTriggerGenerator(final RbacView rbacDef, final String liqibaseTagPrefix) { + this.rbacDef = rbacDef; + this.liquibaseTagPrefix = liqibaseTagPrefix; + } + + void generateTo(final StringWriter plPgSql) { + if (isInsertPermissionGrantedToGlobalGuest()) { + // any user is allowed to insert new rows => no insert check needed + return; + } + + generateInsertGrants(plPgSql); + generateInsertPermissionChecks(plPgSql); + } + + private void generateInsertGrants(final StringWriter plPgSql) { + if (isInsertPermissionIsNotGrantedAtAll()) { + generateInsertPermissionTriggerAlwaysDisallow(plPgSql); + } else { + generateInsertPermissionGrants(plPgSql); + } + } + + private void generateInsertPermissionGrants(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + """, + with("liquibaseTagPrefix", liquibaseTagPrefix)); + + getInsertGrants().forEach( g -> { + plPgSql.writeLn(""" + -- granting INSERT permission to ${rawSubTable} ---------------------------- + """, + with("rawSubTable", g.getSuperRoleDef().getEntityAlias().getRawTableName())); + + if (isGrantToADifferentTable(g)) { + plPgSql.writeLn( + """ + /* + Grants INSERT INTO ${rawSubTable} permissions to specified role of pre-existing ${rawSuperTable} rows. + */ + do language plpgsql $$ + declare + row ${rawSuperTable}; + begin + call defineContext('create INSERT INTO ${rawSubTable} permissions for pre-exising ${rawSuperTable} rows'); + + FOR row IN SELECT * FROM ${rawSuperTable} + ${whenCondition} + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', '${rawSubTable}'), + ${superRoleRef}); + END LOOP; + end; + $$; + """, + with("whenCondition", g.getSuperRoleDef().getEntityAlias().isCaseDependent() + // TODO.impl: 'type' needs to be dynamically generated + ? "WHERE type = '${value}'" + .replace("${value}", g.getSuperRoleDef().getEntityAlias().usingCase().value) + : "-- unconditional for all rows in that table"), + with("rawSuperTable", g.getSuperRoleDef().getEntityAlias().getRawTableName()), + with("rawSubTable", g.getPermDef().getEntityAlias().getRawTableName()), + with("superRoleRef", toRoleDescriptor(g.getSuperRoleDef(), "row"))); + } else { + plPgSql.writeLn(""" + -- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, + -- because there cannot yet be any pre-existing rows in the same table yet. + """, + with("rawSuperTable", g.getSuperRoleDef().getEntityAlias().getRawTableName()), + with("rawSubTable", g.getPermDef().getEntityAlias().getRawTableName())); + } + + plPgSql.writeLn(""" + /** + Grants ${rawSubTable} INSERT permission to specified role of new ${rawSuperTable} rows. + */ + create or replace function new_${rawSubTable}_grants_insert_to_${rawSuperTable}_tf() + returns trigger + language plpgsql + strict as $$ + begin + ${ifConditionThen} + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', '${rawSubTable}'), + ${superRoleRef}); + ${ifConditionEnd} + return NEW; + end; $$; + + -- z_... is to put it at the end of after insert triggers, to make sure the roles exist + create trigger z_new_${rawSubTable}_grants_insert_to_${rawSuperTable}_tg + after insert on ${rawSuperTable} + for each row + execute procedure new_${rawSubTable}_grants_insert_to_${rawSuperTable}_tf(); + """, + with("ifConditionThen", g.getSuperRoleDef().getEntityAlias().isCaseDependent() + // TODO.impl: .type needs to be dynamically generated + ? "if NEW.type = '" + g.getSuperRoleDef().getEntityAlias().usingCase().value + "' then" + : "-- unconditional for all rows in that table"), + with("ifConditionEnd", g.getSuperRoleDef().getEntityAlias().isCaseDependent() + ? "end if;" + : "-- end."), + with("superRoleRef", toRoleDescriptor(g.getSuperRoleDef(), NEW.name())), + with("rawSuperTable", g.getSuperRoleDef().getEntityAlias().getRawTableName()), + with("rawSubTable", g.getPermDef().getEntityAlias().getRawTableName())); + + }); + } + + private void generateInsertPermissionTriggerAlwaysDisallow(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-ALWAYS-DISALLOW-INSERT:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + """, + with("liquibaseTagPrefix", liquibaseTagPrefix)); + + plPgSql.writeLn(""" + /** + Checks if the user or assumed roles are allowed to insert a row to ${rawSubTable}, + where only global-admin has that permission. + */ + create or replace function ${rawSubTable}_insert_permission_missing_tf() + returns trigger + language plpgsql as $$ + begin + raise exception '[403] insert into ${rawSubTable} values(%) not allowed regardless of current subject, no insert permissions granted at all', NEW; + end; $$; + + create trigger ${rawSubTable}_insert_permission_check_tg + before insert on ${rawSubTable} + for each row + execute procedure ${rawSubTable}_insert_permission_missing_tf(); + """, + with("rawSubTable", rbacDef.getRootEntityAlias().getRawTableName())); + + plPgSql.writeLn("--//"); + } + + private void generateInsertPermissionChecks(final StringWriter plPgSql) { + generateInsertPermissionsCheckHeader(plPgSql); + + plPgSql.indented(1, () -> { + getInsertGrants().forEach(g -> { + generateInsertPermissionChecksForSingleGrant(plPgSql, g); + }); + plPgSql.chopTail(" or\n"); + }); + + generateInsertPermissionsChecksFooter(plPgSql); + } + + private void generateInsertPermissionsCheckHeader(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${rawSubTable}-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + + /** + Checks if the user respectively the assumed roles are allowed to insert a row to ${rawSubTable}. + */ + create or replace function ${rawSubTable}_insert_permission_check_tf() + returns trigger + language plpgsql as $$ + declare + superObjectUuid uuid; + begin + """, + with("rawSubTable", rbacDef.getRootEntityAlias().getRawTableName())); + plPgSql.chopEmptyLines(); + } + + private void generateInsertPermissionChecksForSingleGrant(final StringWriter plPgSql, final RbacView.RbacGrantDefinition g) { + final RbacView.EntityAlias superRoleEntityAlias = g.getSuperRoleDef().getEntityAlias(); + + final var caseCondition = g.isConditional() + ? ("NEW.type in (" + toStringList(g.getForCases()) + ") and ") + : ""; + + if (g.getSuperRoleDef().isGlobal(GUEST)) { + plPgSql.writeLn( + """ + -- check INSERT INSERT permission for global anyone + if ${caseCondition}true then + return NEW; + end if; + """, + with("caseCondition", caseCondition)); + } else if (g.getSuperRoleDef().isGlobal(ADMIN)) { + plPgSql.writeLn( + """ + -- check INSERT INSERT if global ADMIN + if ${caseCondition}isGlobalAdmin() then + return NEW; + end if; + """, + with("caseCondition", caseCondition)); + } else if (g.getSuperRoleDef().getEntityAlias().isFetchedByDirectForeignKey()) { + plPgSql.writeLn( + """ + -- check INSERT permission via direct foreign key: NEW.${refColumn} + if ${caseCondition}hasInsertPermission(NEW.${refColumn}, '${rawSubTable}') then + return NEW; + end if; + """, + with("caseCondition", caseCondition), + with("refColumn", superRoleEntityAlias.dependsOnColumName()), + with("rawSubTable", rbacDef.getRootEntityAlias().getRawTableName())); + } else { + plPgSql.writeLn( + """ + -- check INSERT permission via indirect foreign key: NEW.${refColumn} + superObjectUuid := (${fetchSql}); + assert superObjectUuid is not null, 'object uuid fetched depending on ${rawSubTable}.${refColumn} must not be null, also check fetchSql in RBAC DSL'; + if ${caseCondition}hasInsertPermission(superObjectUuid, '${rawSubTable}') then + return NEW; + end if; + """, + with("caseCondition", caseCondition), + with("rawSubTable", rbacDef.getRootEntityAlias().getRawTableName()), + with("refColumn", superRoleEntityAlias.dependsOnColumName()), + with("fetchSql", g.getSuperRoleDef().getEntityAlias().fetchSql().sql), + with("columns", g.getSuperRoleDef().getEntityAlias().aliasName() + ".uuid"), + with("ref", NEW.name())); + } + } + + private void generateInsertPermissionsChecksFooter(final StringWriter plPgSql) { + plPgSql.writeLn(); + plPgSql.writeLn(""" + raise exception '[403] insert into ${rawSubTable} values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); + end; $$; + + create trigger ${rawSubTable}_insert_permission_check_tg + before insert on ${rawSubTable} + for each row + execute procedure ${rawSubTable}_insert_permission_check_tf(); + --// + """, + with("rawSubTable", rbacDef.getRootEntityAlias().getRawTableName())); + } + + private String toStringList(final Set cases) { + return cases.stream().map(c -> "'" + c.value + "'").collect(joining(", ")); + } + + private boolean isGrantToADifferentTable(final RbacView.RbacGrantDefinition g) { + return !rbacDef.getRootEntityAlias().getRawTableName().equals(g.getSuperRoleDef().getEntityAlias().getRawTableName()); + } + + private Stream getInsertGrants() { + return rbacDef.getGrantDefs().stream() + .filter(g -> g.grantType() == PERM_TO_ROLE) + .filter(g -> g.getPermDef().toCreate && g.getPermDef().getPermission() == INSERT); + } + + private boolean isInsertPermissionIsNotGrantedAtAll() { + return getInsertGrants().findAny().isEmpty(); + } + + private boolean isInsertPermissionGrantedToGlobalGuest() { + return getInsertGrants().anyMatch(g -> + g.getSuperRoleDef().getEntityAlias().isGlobal() && g.getSuperRoleDef().getRole() == GUEST); + } + + private Optional getOptionalInsertGrant() { + return getInsertGrants() + .reduce(singleton()); + } + + private Optional getOptionalInsertSuperRole() { + return getInsertGrants() + .map(RbacView.RbacGrantDefinition::getSuperRoleDef) + .reduce(singleton()); + } + + private static BinaryOperator singleton() { + return (x, y) -> { + if ( !x.equals(y) ) { + return x; + // throw new IllegalStateException("only a single INSERT permission grant allowed"); + } + return x; + }; + } + + private static String toVar(final RbacView.RbacRoleDefinition roleDef) { + return uncapitalize(roleDef.getEntityAlias().simpleName()) + capitalize(roleDef.getRole().name()); + } + + + private String toRoleDescriptor(final RbacView.RbacRoleDefinition roleDef, final String ref) { + final var functionName = toVar(roleDef); + if (roleDef.getEntityAlias().isGlobal()) { + return functionName + "()"; + } + return functionName + "(" + ref + ")"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/PostgresTriggerReference.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/PostgresTriggerReference.java new file mode 100644 index 00000000..4fb5cb61 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/PostgresTriggerReference.java @@ -0,0 +1,5 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +public enum PostgresTriggerReference { + NEW, OLD +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacIdentityViewGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacIdentityViewGenerator.java new file mode 100644 index 00000000..50b404eb --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacIdentityViewGenerator.java @@ -0,0 +1,48 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; + +public class RbacIdentityViewGenerator { + private final RbacView rbacDef; + private final String liquibaseTagPrefix; + private final String simpleEntityVarName; + private final String rawTableName; + + public RbacIdentityViewGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) { + this.rbacDef = rbacDef; + this.liquibaseTagPrefix = liquibaseTagPrefix; + this.simpleEntityVarName = rbacDef.getRootEntityAlias().simpleName(); + this.rawTableName = rbacDef.getRootEntityAlias().getRawTableName(); + } + + void generateTo(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-IDENTITY-VIEW:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + """, + with("liquibaseTagPrefix", liquibaseTagPrefix)); + + plPgSql.writeLn( + switch (rbacDef.getIdentityViewSqlQuery().part) { + case SQL_PROJECTION -> """ + call generateRbacIdentityViewFromProjection('${rawTableName}', + $idName$ + ${identityViewSqlPart} + $idName$); + """; + case SQL_QUERY -> """ + call generateRbacIdentityViewFromQuery('${rawTableName}', + $idName$ + ${identityViewSqlPart} + $idName$); + """; + default -> throw new IllegalStateException("illegal SQL part given"); + }, + with("identityViewSqlPart", StringWriter.indented(2, rbacDef.getIdentityViewSqlQuery().sql)), + with("rawTableName", rawTableName)); + + plPgSql.writeLn("--//"); + plPgSql.writeLn(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacObjectGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacObjectGenerator.java new file mode 100644 index 00000000..a7377301 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacObjectGenerator.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; + +public class RbacObjectGenerator { + + private final String liquibaseTagPrefix; + private final String rawTableName; + + public RbacObjectGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) { + this.liquibaseTagPrefix = liquibaseTagPrefix; + this.rawTableName = rbacDef.getRootEntityAlias().getRawTableName(); + } + + void generateTo(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-OBJECT:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + call generateRelatedRbacObject('${rawTableName}'); + --// + + """, + with("liquibaseTagPrefix", liquibaseTagPrefix), + with("rawTableName", rawTableName)); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRestrictedViewGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRestrictedViewGenerator.java new file mode 100644 index 00000000..b5757865 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRestrictedViewGenerator.java @@ -0,0 +1,41 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + + +import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.indented; +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; + +public class RbacRestrictedViewGenerator { + private final RbacView rbacDef; + private final String liquibaseTagPrefix; + private final String rawTableName; + + public RbacRestrictedViewGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) { + this.rbacDef = rbacDef; + this.liquibaseTagPrefix = liquibaseTagPrefix; + this.rawTableName = rbacDef.getRootEntityAlias().getRawTableName(); + } + + void generateTo(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-RESTRICTED-VIEW:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + call generateRbacRestrictedView('${rawTableName}', + $orderBy$ + ${orderBy} + $orderBy$, + $updates$ + ${updates} + $updates$); + --// + + """, + with("liquibaseTagPrefix", liquibaseTagPrefix), + with("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)), + with("updates", indented(2, rbacDef.getUpdatableColumns().stream() + .map(c -> c + " = new." + c) + .collect(joining(",\n")))), + with("rawTableName", rawTableName)); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRoleDescriptorsGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRoleDescriptorsGenerator.java new file mode 100644 index 00000000..dab3ab01 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacRoleDescriptorsGenerator.java @@ -0,0 +1,30 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; + +public class RbacRoleDescriptorsGenerator { + + private final String liquibaseTagPrefix; + private final String simpleEntityVarName; + private final String rawTableName; + + public RbacRoleDescriptorsGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) { + this.liquibaseTagPrefix = liquibaseTagPrefix; + this.simpleEntityVarName = rbacDef.getRootEntityAlias().simpleName(); + this.rawTableName = rbacDef.getRootEntityAlias().getRawTableName(); + } + + void generateTo(final StringWriter plPgSql) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + call generateRbacRoleDescriptors('${simpleEntityVarName}', '${rawTableName}'); + --// + + """, + with("liquibaseTagPrefix", liquibaseTagPrefix), + with("simpleEntityVarName", simpleEntityVarName), + with("rawTableName", rawTableName)); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacView.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacView.java new file mode 100644 index 00000000..ed3a1486 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacView.java @@ -0,0 +1,1268 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import org.reflections.Reflections; +import org.reflections.scanners.TypeAnnotationsScanner; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static java.lang.reflect.Modifier.isStatic; +import static java.util.Arrays.asList; +import static java.util.Arrays.stream; +import static java.util.Collections.max; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.PERM_TO_ROLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.ROLE_TO_ROLE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.Part.AUTO_FETCH; +import static org.apache.commons.collections4.SetUtils.hashSet; +import static org.apache.commons.lang3.StringUtils.uncapitalize; + +@Getter +// TODO.refa: rename to RbacDSL +public class RbacView { + + public static final String GLOBAL = "global"; + public static final String OUTPUT_BASEDIR = "src/main/resources/db/changelog"; + + private final EntityAlias rootEntityAlias; + + private final Set userDefs = new LinkedHashSet<>(); + private final Set roleDefs = new LinkedHashSet<>(); + private final Set permDefs = new LinkedHashSet<>(); + private final Map entityAliases = new HashMap<>() { + + @Override + public EntityAlias put(final String key, final EntityAlias value) { + if (containsKey(key)) { + throw new IllegalArgumentException("duplicate entityAlias: " + key); + } + return super.put(key, value); + } + }; + private final Set updatableColumns = new LinkedHashSet<>(); + private final Set grantDefs = new LinkedHashSet<>(); + private final Set allCases = new LinkedHashSet<>(); + + private String discriminatorColumName; + private CaseDef processingCase; + private SQL identityViewSqlQuery; + private SQL orderBySqlExpression; + private EntityAlias rootEntityAliasProxy; + private RbacRoleDefinition previousRoleDef; + private Set limitDiagramToAliasNames; + private final Map cases = new LinkedHashMap<>() { + @Override + public CaseDef put(final String key, final CaseDef value) { + if (containsKey(key)) { + throw new IllegalArgumentException("duplicate case: " + key); + } + return super.put(key, value); + } + }; + + /** Crates an RBAC definition template for the given entity class and defining the given alias. + * + * @param alias + * an alias name for this entity/table, which can be used in further grants + * + * @param entityClass + * the Java class for which this RBAC definition is to be defined + * (the class to which the calling method belongs) + * + * @return + * the newly created RBAC definition template + * + * @param + * a JPA entity class extending RbacObject + */ + public static RbacView rbacViewFor(final String alias, final Class entityClass) { + return new RbacView(alias, entityClass); + } + + RbacView(final String alias, final Class entityClass) { + rootEntityAlias = new EntityAlias(alias, entityClass); + entityAliases.put(alias, rootEntityAlias); + new RbacUserReference(CREATOR); + entityAliases.put("global", new EntityAlias("global")); + } + + /** + * Specifies, which columns of the restricted view are updatable at all. + * + * @param columnNames + * A list of the updatable columns. + * + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView withUpdatableColumns(final String... columnNames) { + Collections.addAll(updatableColumns, columnNames); + verifyVersionColumnExists(); + return this; + } + + /** Specifies the SQL query which creates the identity view for this entity. + * + *

An identity view is a view which maps an objectUuid to an idName. + * The idName should be a human-readable representation of the row, but as short as possible. + * The idName must only consist of letters (A-Z, a-z), digits (0-9), dash (-), dot (.) and unserscore '_'. + * It's used to create the object-specific-role-names like test_customer#abc:ADMIN - here 'abc' is the idName. + * The idName not necessarily unique in a table, but it should be avoided. + *

+ * + * @param sqlExpression + * Either specify an SQL projection (the part between SELECT and FROM), e.g. `SQL.projection("columnName") + * or the whole SELECT query returning the uuid and idName columns, + * e.g. `SQL.query("SELECT ... AS uuid, ... AS idName FROM ... JOIN ..."). + * Only add really important columns, just enough to create a short human-readable representation. + * + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView withIdentityView(final SQL sqlExpression) { + this.identityViewSqlQuery = sqlExpression; + return this; + } + + /** + * Specifies a ORDER BY clause for the generated restricted view. + * + *

A restricted view is generated, no matter if the order was specified or not.

+ * + * @param orderBySqlExpression + * That's the part behind `ORDER BY`, e.g. `SQL.expression("prefix"). + * + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView withRestrictedViewOrderBy(final SQL orderBySqlExpression) { + this.orderBySqlExpression = orderBySqlExpression; + return this; + } + + /** + * Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table. + * + * @param role + * OWNER, ADMIN, AGENT etc. + * @param with + * a lambda which receives the created role to create grants and permissions to and from the newly created role, + * e.g. the owning user, incoming superroles, outgoing subroles + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView createRole(final Role role, final Consumer with) { + final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate(); + with.accept(newRoleDef); + previousRoleDef = newRoleDef; + return this; + } + + /** + * Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table, + * which is becomes sub-role of the previously created role. + * + * @param role + * OWNER, ADMIN, AGENT etc. + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView createSubRole(final Role role) { + final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate(); + findOrCreateGrantDef(newRoleDef, previousRoleDef).toCreate(); + previousRoleDef = newRoleDef; + return this; + } + + + /** + * Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table, + * which is becomes sub-role of the previously created role. + * + * @param role + * OWNER, ADMIN, AGENT etc. + * @param with + * a lambda which receives the created role to create grants and permissions to and from the newly created role, + * e.g. the owning user, incoming superroles, outgoing subroles + * @return + * the `this` instance itself to allow chained calls. + */ + public RbacView createSubRole(final Role role, final Consumer with) { + final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate(); + findOrCreateGrantDef(newRoleDef, previousRoleDef).toCreate(); + with.accept(newRoleDef); + previousRoleDef = newRoleDef; + return this; + } + + /** + * Specifies that the given permission is to be created for each new row in the target table. + * + *

Grants to permissions created by this method have to be specified separately, + * often it's easier to read to use createRole/createSubRole and use with.permission(...).

+ * + * @param permission + * e.g. INSERT, SELECT, UPDATE, DELETE + * + * @return + * the newly created permission definition + */ + public RbacPermissionDefinition createPermission(final Permission permission) { + return createPermission(rootEntityAlias, permission); + } + + /** + * Specifies that the given permission is to be created for each new row in the target table, + * but for another table, e.g. a table with details data with different access rights. + * + *

Grants to permissions created by this method have to be specified separately, + * often it's easier to read to use createRole/createSubRole and use with.permission(...).

+ * + * @param entityAliasName + * A previously defined entity alias name. + * + * @param permission + * e.g. INSERT, SELECT, UPDATE, DELETE + * + * @return + * the newly created permission definition + */ + public RbacPermissionDefinition createPermission(final String entityAliasName, final Permission permission) { + return createPermission(findEntityAlias(entityAliasName), permission); + } + + private RbacPermissionDefinition createPermission(final EntityAlias entityAlias, final Permission permission) { + return permDefs.stream() + .filter(p -> p.permission == permission && p.entityAlias == entityAlias) + .findFirst() + // .map(g -> g.forCase(processingCase)) TODO.impl: not implemented case dependent + .orElseGet(() -> new RbacPermissionDefinition(entityAlias, permission, null, true)); + } + + public RbacView declarePlaceholderEntityAliases(final String... aliasNames) { + for (String alias : aliasNames) { + entityAliases.put(alias, new EntityAlias(alias)); + } + return this; + } + + /** + * Imports the RBAC template from the given entity class and defines an alias name for it. + * This method is especially for proxy-entities, if the root entity does not have its own + * roles, a proxy-entity can be specified and its roles can be used instead. + * + * @param aliasName + * An alias name for the entity class. The same entity class can be imported multiple times, + * if multiple references to its table exist, then distinct alias names habe to be defined. + * + * @param entityClass + * A JPA entity class extending RbacObject which also implements an `rbac` method returning + * its RBAC specification. + * + * @param fetchSql + * An SQL SELECT statement which fetches the referenced row. Use `${REF}` to speficiy the + * newly created or updated row (will be replaced by NEW/OLD from the trigger method). + * + * @param dependsOnColum + * The column, usually containing an uuid, on which this other table depends. + * + * @return + * the newly created permission definition + * + * @param + * a JPA entity class extending RbacObject + */ + public RbacView importRootEntityAliasProxy( + final String aliasName, + final Class entityClass, + final ColumnValue forCase, + final SQL fetchSql, + final Column dependsOnColum) { + if (rootEntityAliasProxy != null) { + throw new IllegalStateException("there is already an entityAliasProxy: " + rootEntityAliasProxy); + } + rootEntityAliasProxy = importEntityAliasImpl(aliasName, entityClass, forCase, fetchSql, dependsOnColum, false, NOT_NULL); + return this; + } + + /** + * Imports the RBAC template from the given entity class and defines an alias name for it. + * This method is especially to declare sub-entities, e.g. details to a main object. + * + * @see {@link} + * + * @return + * the newly created permission definition + * + * @param + * a JPA entity class extending RbacObject + */ + public RbacView importSubEntityAlias( + final String aliasName, final Class entityClass, + final SQL fetchSql, final Column dependsOnColum) { + importEntityAliasImpl(aliasName, entityClass, usingDefaultCase(), fetchSql, dependsOnColum, true, NOT_NULL); + return this; + } + + /** + * Imports the RBAC template from the given entity class and defines an anlias name for it. + * + * @param aliasName + * An alias name for the entity class. The same entity class can be imported multiple times, + * if multiple references to its table exist, then distinct alias names habe to be defined. + * + * @param entityClass + * A JPA entity class extending RbacObject which also implements an `rbac` method returning + * its RBAC specification. + * + * @param usingCase + * Only use this case value for a switch within the rbac rules. + * + * @param fetchSql + * An SQL SELECT statement which fetches the referenced row. Use `${REF}` to speficiy the + * newly created or updated row (will be replaced by NEW/OLD from the trigger method). + * + * @param dependsOnColum + * The column, usually containing an uuid, on which this other table depends. + * + * @param nullable + * Specifies whether the dependsOnColum is nullable or not. + * + * @return + * the newly created permission definition + * + * @param + * a JPA entity class extending RbacObject + */ + public RbacView importEntityAlias( + final String aliasName, final Class entityClass, final ColumnValue usingCase, + final Column dependsOnColum, final SQL fetchSql, final Nullable nullable) { + importEntityAliasImpl(aliasName, entityClass, usingCase, fetchSql, dependsOnColum, false, nullable); + return this; + } + + private EntityAlias importEntityAliasImpl( + final String aliasName, final Class entityClass, final ColumnValue usingCase, + final SQL fetchSql, final Column dependsOnColum, boolean asSubEntity, final Nullable nullable) { + + final var entityAlias = ofNullable(entityAliases.get(aliasName)) + .orElseGet(() -> { + final var ea = new EntityAlias(aliasName, entityClass, usingCase, fetchSql, dependsOnColum, asSubEntity, nullable); + entityAliases.put(aliasName, ea); + return ea; + }); + + try { + // TODO.rbac: this only works for directly recursive RBAC definitions, not for indirect recursion + final var rbacDef = entityClass == rootEntityAlias.entityClass + ? this + : rbacDefinition(entityClass); + importAsAlias(aliasName, rbacDef, usingCase, asSubEntity); + } catch (final ReflectiveOperationException exc) { + throw new RuntimeException("cannot import entity: " + entityClass, exc); + } + return entityAlias; + } + + private static RbacView rbacDefinition(final Class entityClass) + throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { + return (RbacView) entityClass.getMethod("rbac").invoke(null); + } + + private RbacView importAsAlias(final String aliasName, final RbacView importedRbacView, final ColumnValue forCase, final boolean asSubEntity) { + final var mapper = new AliasNameMapper(importedRbacView, aliasName, + asSubEntity ? entityAliases.keySet() : null); + copyOf(importedRbacView.getEntityAliases().values()).stream() + .filter(entityAlias -> !importedRbacView.isRootEntityAlias(entityAlias)) + .filter(entityAlias -> !entityAlias.isGlobal()) + .filter(entityAlias -> !asSubEntity || !entityAliases.containsKey(entityAlias.aliasName)) + .forEach(entityAlias -> { + final String mappedAliasName = mapper.map(entityAlias.aliasName); + entityAliases.put(mappedAliasName, new EntityAlias(mappedAliasName, entityAlias.entityClass)); + }); + copyOf(importedRbacView.getRoleDefs()).forEach(roleDef -> { + new RbacRoleDefinition(findEntityAlias(mapper.map(roleDef.entityAlias.aliasName)), roleDef.role); + }); + copyOf(importedRbacView.getGrantDefs()).forEach(grantDef -> { + if ( grantDef.grantType() == ROLE_TO_ROLE && grantDef.matchesCase(forCase) ) { + final var importedGrantDef = findOrCreateGrantDef( + findRbacRole( + mapper.map(grantDef.getSubRoleDef().entityAlias.aliasName), + grantDef.getSubRoleDef().getRole()), + findRbacRole( + mapper.map(grantDef.getSuperRoleDef().entityAlias.aliasName), + grantDef.getSuperRoleDef().getRole()) + ); + if (!grantDef.isAssumed()) { + importedGrantDef.unassumed(); + } + } + }); + return this; + } + + public RbacView switchOnColumn(final String discriminatorColumName, final CaseDef... caseDefs) { + this.discriminatorColumName = discriminatorColumName; + allCases.addAll(stream(caseDefs).toList()); + + stream(caseDefs).forEach(caseDef -> { + this.processingCase = caseDef; + caseDef.def.accept(this); + this.processingCase = null; + }); + return this; + } + + private static List copyOf(final Collection eas) { + return eas.stream().toList(); + } + + private void verifyVersionColumnExists() { + final var clazz = rootEntityAlias.entityClass; + if (!hasVersionColumn(clazz)) { + throw new IllegalArgumentException("@Version field required in updatable entity " + rootEntityAlias.entityClass); + } + } + + private static boolean hasVersionColumn(final Class clazz) { + if (stream(clazz.getDeclaredFields()).anyMatch(f -> f.getAnnotation(Version.class) != null)) { + return true; + } + if (clazz.getSuperclass() != null) { + return hasVersionColumn(clazz.getSuperclass()); + } + return false; + } + + /** + * Starts declaring a grant to a given role. + * + * @param entityAlias + * A previously speciried entity alias name. + * @param role + * OWNER, ADMIN, AGENT, ... + * @return + * a grant builder + */ + public RbacGrantBuilder toRole(final String entityAlias, final Role role) { + return new RbacGrantBuilder(entityAlias, role); + } + + public RbacExampleRole forExampleRole(final String entityAlias, final Role role) { + return new RbacExampleRole(entityAlias, role); + } + + private RbacGrantDefinition grantRoleToUser(final RbacRoleDefinition roleDefinition, final RbacUserReference user) { + return findOrCreateGrantDef(roleDefinition, user).toCreate(); + } + + private RbacGrantDefinition grantPermissionToRole( + final RbacPermissionDefinition permDef, + final RbacRoleDefinition roleDef) { + return findOrCreateGrantDef(permDef, roleDef).toCreate(); + } + + private RbacGrantDefinition grantSubRoleToSuperRole( + final RbacRoleDefinition subRoleDefinition, + final RbacRoleDefinition superRoleDefinition) { + return findOrCreateGrantDef(subRoleDefinition, superRoleDefinition).toCreate(); + } + + boolean isRootEntityAlias(final EntityAlias entityAlias) { + return entityAlias == this.rootEntityAlias; + } + + public boolean isEntityAliasProxy(final EntityAlias entityAlias) { + return entityAlias == rootEntityAliasProxy; + } + + public SQL getOrderBySqlExpression() { + if (orderBySqlExpression == null) { + return identityViewSqlQuery; + } + return orderBySqlExpression; + } + + public void generateWithBaseFileName(final String baseFileName) { + if (allCases.size() > 1) { + allCases.forEach(caseDef -> { + final var fileName = baseFileName + (caseDef.isDefaultCase() ? "" : "-" + caseDef.value) + ".md"; + new RbacViewMermaidFlowchartGenerator(this, caseDef) + .generateToMarkdownFile(Path.of(OUTPUT_BASEDIR, fileName)); + }); + } else { + new RbacViewMermaidFlowchartGenerator(this).generateToMarkdownFile(Path.of(OUTPUT_BASEDIR, baseFileName + ".md")); + } + new RbacViewPostgresGenerator(this).generateToChangeLog(Path.of(OUTPUT_BASEDIR, baseFileName + ".sql")); + } + + public RbacView limitDiagramTo(final String... aliasNames) { + this.limitDiagramToAliasNames = Set.of(aliasNames); + return this; + } + + public boolean renderInDiagram(final EntityAlias ea) { + return limitDiagramToAliasNames == null || limitDiagramToAliasNames.contains(ea.aliasName()); + } + + public boolean renderInDiagram(final RbacGrantDefinition g) { + if ( limitDiagramToAliasNames == null ) { + return true; + } + return switch (g.grantType()) { + case ROLE_TO_USER -> + renderInDiagram(g.getSubRoleDef().getEntityAlias()); + case ROLE_TO_ROLE -> + renderInDiagram(g.getSuperRoleDef().getEntityAlias()) && renderInDiagram(g.getSubRoleDef().getEntityAlias()); + case PERM_TO_ROLE -> + renderInDiagram(g.getSuperRoleDef().getEntityAlias()) && renderInDiagram(g.getPermDef().getEntityAlias()); + }; + } + + public class RbacGrantBuilder { + + private final RbacRoleDefinition superRoleDef; + + private RbacGrantBuilder(final String entityAlias, final Role role) { + this.superRoleDef = findRbacRole(entityAlias, role); + } + + public RbacView grantRole(final String entityAlias, final Role role) { + findOrCreateGrantDef(findRbacRole(entityAlias, role), superRoleDef).toCreate(); + return RbacView.this; + } + + public RbacView grantPermission(final Permission perm) { + final var forTable = rootEntityAlias.getRawTableName(); + findOrCreateGrantDef(findRbacPerm(rootEntityAlias, perm, forTable), superRoleDef).toCreate(); + return RbacView.this; + } + + } + + public enum Nullable { + NOT_NULL, // DEFAULT + NULLABLE + } + + @Getter + @EqualsAndHashCode + public class RbacGrantDefinition { + + private final RbacUserReference userDef; + private final RbacRoleDefinition superRoleDef; + private final RbacRoleDefinition subRoleDef; + private final RbacPermissionDefinition permDef; + private boolean assumed = true; + private boolean toCreate = false; + private Set forCases = new LinkedHashSet<>(); + + @Override + public String toString() { + final var arrow = isAssumed() ? " --> " : " -- // --> "; + final var grant = switch (grantType()) { + case ROLE_TO_USER -> userDef.toString() + arrow + subRoleDef.toString(); + case ROLE_TO_ROLE -> superRoleDef + arrow + subRoleDef; + case PERM_TO_ROLE -> superRoleDef + arrow + permDef; + }; + final var condition = isConditional() + ? (" (" +forCases.stream().map(CaseDef::toString).collect(Collectors.joining("||")) + ")") + : ""; + return grant + condition; + } + + RbacGrantDefinition(final RbacRoleDefinition subRoleDef, final RbacRoleDefinition superRoleDef, final CaseDef forCase) { + this.userDef = null; + this.subRoleDef = subRoleDef; + this.superRoleDef = superRoleDef; + this.permDef = null; + this.forCases = forCase != null ? hashSet(forCase) : null; + register(this); + } + + public RbacGrantDefinition(final RbacPermissionDefinition permDef, final RbacRoleDefinition roleDef, + final CaseDef forCase) { + this.userDef = null; + this.subRoleDef = null; + this.superRoleDef = roleDef; + this.permDef = permDef; + this.forCases = forCase != null ? hashSet(forCase) : null; + register(this); + } + + public RbacGrantDefinition(final RbacRoleDefinition roleDef, final RbacUserReference userDef) { + this.userDef = userDef; + this.subRoleDef = roleDef; + this.superRoleDef = null; + this.permDef = null; + register(this); + } + + private void register(final RbacGrantDefinition rbacGrantDefinition) { + grantDefs.add(rbacGrantDefinition); + } + + @NotNull + GrantType grantType() { + return permDef != null ? PERM_TO_ROLE + : userDef != null ? GrantType.ROLE_TO_USER + : ROLE_TO_ROLE; + } + + boolean isAssumed() { + return assumed; + } + + + RbacGrantDefinition forCase(final CaseDef processingCase) { + forCases.add(processingCase); + return this; + } + + boolean isConditional() { + return forCases != null && !forCases.isEmpty() && forCases.size() c.isCase(requestedCase)) + || forCases.stream().anyMatch(CaseDef::isDefaultCase) && !allCases.stream().anyMatch(c -> c.isCase(requestedCase)); + return noCasesDefined || generateForAllCases || isGrantedForRequestedCase; + } + + boolean isToCreate() { + return toCreate; + } + + RbacGrantDefinition toCreate() { + toCreate = true; + return this; + } + + boolean dependsOnColumn(final String columnName) { + return dependsRoleDefOnColumnName(this.superRoleDef, columnName) + || dependsRoleDefOnColumnName(this.subRoleDef, columnName); + } + + private Boolean dependsRoleDefOnColumnName(final RbacRoleDefinition superRoleDef, final String columnName) { + return ofNullable(superRoleDef) + .map(r -> r.getEntityAlias().dependsOnColum()) + .map(d -> columnName.equals(d.column)) + .orElse(false); + } + + public RbacGrantDefinition unassumed() { + this.assumed = false; + return this; + } + + public long level() { + return max(asList( + superRoleDef != null ? superRoleDef.entityAlias.level() : 0, + subRoleDef != null ? subRoleDef.entityAlias.level() : 0, + permDef != null ? permDef.entityAlias.level() : 0)); + } + + public enum GrantType { + ROLE_TO_USER, + ROLE_TO_ROLE, + PERM_TO_ROLE + } + } + + public class RbacExampleRole { + + final EntityAlias subRoleEntity; + final Role subRole; + private EntityAlias superRoleEntity; + Role superRole; + + public RbacExampleRole(final String entityAlias, final Role role) { + this.subRoleEntity = findEntityAlias(entityAlias); + this.subRole = role; + } + + public RbacView wouldBeGrantedTo(final String entityAlias, final Role role) { + this.superRoleEntity = findEntityAlias(entityAlias); + this.superRole = role; + return RbacView.this; + } + } + + @Getter + @EqualsAndHashCode + public class RbacPermissionDefinition { + + final EntityAlias entityAlias; + final Permission permission; + final String tableName; + final boolean toCreate; + + private RbacPermissionDefinition(final EntityAlias entityAlias, final Permission permission, final String tableName, + final boolean toCreate) { + this.entityAlias = entityAlias; + this.permission = permission; + this.tableName = tableName; + this.toCreate = toCreate; + permDefs.add(this); + } + + /** + * Grants the permission under definition to the given role. + * + * @param entityAlias + * A previously declared entity alias name. + * @param role + * OWNER, ADMIN, ... + * @return + * The RbacView specification to which this permission definition belongs. + */ + public RbacView grantedTo(final String entityAlias, final Role role) { + findOrCreateGrantDef(this, findRbacRole(entityAlias, role)).toCreate(); + return RbacView.this; + } + + @Override + public String toString() { + return "perm:" + entityAlias.aliasName + permission + ofNullable(tableName).map(tn -> ":" + tn).orElse(""); + } + } + + @Getter + @EqualsAndHashCode + public class RbacRoleDefinition { + + private final EntityAlias entityAlias; + private final Role role; + private boolean toCreate; + + public RbacRoleDefinition(final EntityAlias entityAlias, final Role role) { + this.entityAlias = entityAlias; + this.role = role; + roleDefs.add(this); + } + + public RbacRoleDefinition toCreate() { + this.toCreate = true; + return this; + } + + /** + * Specifies which user becomes the owner of newly created objects. + * @param userRole + * GLOBAL_ADMIN, CREATOR, ... + * @return + * The grant definition for further chained calls. + */ + public RbacGrantDefinition owningUser(final RbacUserReference.UserRole userRole) { + return grantRoleToUser(this, findUserRef(userRole)); + } + + /** + * Specifies which permission is to be created for newly created objects. + * @param permission + * INSERT, SELECT, ... + * @return + * The grant definition for further chained calls. + */ + public RbacGrantDefinition permission(final Permission permission) { + return grantPermissionToRole(createPermission(entityAlias, permission), this); + } + + /** + * Specifies in incoming super role which gets granted the role under definition. + * + *

Incoming means an incoming grant arrow in our grant-diagrams. + * Super-role means that it's the role to which another role is granted. + * Both means actually the same, just in different aspects.

+ * + * @param entityAlias + * A previously declared entity alias name. + * @param role + * OWNER, ADMIN, ... + * @return + * The grant definition for further chained calls. + */ + public RbacGrantDefinition incomingSuperRole(final String entityAlias, final Role role) { + final var incomingSuperRole = findRbacRole(entityAlias, role); + return grantSubRoleToSuperRole(this, incomingSuperRole); + } + + /** + * Specifies in outgoing sub role which gets granted the role under definition. + * + *

Outgoing means an outgoing grant arrow in our grant-diagrams. + * Sub-role means which is granted to another role. + * Both means actually the same, just in different aspects.

+ * + * @param entityAlias + * A previously declared entity alias name. + * @param role + * OWNER, ADMIN, ... + * @return + * The grant definition for further chained calls. + */ + public RbacGrantDefinition outgoingSubRole(final String entityAlias, final Role role) { + final var outgoingSubRole = findRbacRole(entityAlias, role); + return grantSubRoleToSuperRole(outgoingSubRole, this); + } + + @Override + public String toString() { + return "role:" + entityAlias.aliasName + role; + } + + public boolean isGlobal(final Role role) { + return entityAlias.isGlobal() && this.role == role; + } + } + + public RbacUserReference findUserRef(final RbacUserReference.UserRole userRole) { + return userDefs.stream().filter(u -> u.role == userRole).findFirst().orElseThrow(); + } + + @EqualsAndHashCode + public class RbacUserReference { + + public enum UserRole { + GLOBAL_ADMIN, + CREATOR + } + + final UserRole role; + + public RbacUserReference(final UserRole creator) { + this.role = creator; + userDefs.add(this); + } + + @Override + public String toString() { + return "user:" + role; + } + } + + EntityAlias findEntityAlias(final String aliasName) { + final var found = entityAliases.get(aliasName); + if (found == null) { + throw new IllegalArgumentException("entityAlias not found: " + aliasName); + } + return found; + } + + RbacRoleDefinition findRbacRole(final EntityAlias entityAlias, final Role role) { + return roleDefs.stream() + .filter(r -> r.getEntityAlias() == entityAlias && r.getRole().equals(role)) + .findFirst() + .orElseGet(() -> new RbacRoleDefinition(entityAlias, role)); + } + + public RbacRoleDefinition findRbacRole(final String entityAliasName, final Role role) { + return findRbacRole(findEntityAlias(entityAliasName), role); + + } + + RbacPermissionDefinition findRbacPerm(final EntityAlias entityAlias, final Permission perm, String tableName) { + return permDefs.stream() + .filter(p -> p.getEntityAlias() == entityAlias && p.getPermission() == perm) + .findFirst() + .orElseGet(() -> new RbacPermissionDefinition(entityAlias, perm, tableName, true)); // TODO: true => toCreate + } + + private RbacGrantDefinition findOrCreateGrantDef(final RbacRoleDefinition roleDefinition, final RbacUserReference user) { + return grantDefs.stream() + .filter(g -> g.subRoleDef == roleDefinition && g.userDef == user) + .findFirst() + .orElseGet(() -> new RbacGrantDefinition(roleDefinition, user)); + } + + private RbacGrantDefinition findOrCreateGrantDef(final RbacPermissionDefinition permDef, final RbacRoleDefinition roleDef) { + return grantDefs.stream() + .filter(g -> g.permDef == permDef && g.superRoleDef == roleDef) + .findFirst() + .map(g -> g.forCase(processingCase)) + .orElseGet(() -> new RbacGrantDefinition(permDef, roleDef, processingCase)); + } + + private RbacGrantDefinition findOrCreateGrantDef( + final RbacRoleDefinition subRoleDefinition, + final RbacRoleDefinition superRoleDefinition) { + final var distinctGrantDef = grantDefs.stream() + .filter(g -> g.subRoleDef == subRoleDefinition && g.superRoleDef == superRoleDefinition) + .findFirst() + .map(g -> g.forCase(processingCase)) + .orElseGet(() -> new RbacGrantDefinition(subRoleDefinition, superRoleDefinition, processingCase)); + return distinctGrantDef; + } + + record EntityAlias(String aliasName, Class entityClass, ColumnValue usingCase, SQL fetchSql, Column dependsOnColum, boolean isSubEntity, Nullable nullable) { + + public EntityAlias(final String aliasName) { + this(aliasName, null, null, null, null, false, null); + } + + public EntityAlias(final String aliasName, final Class entityClass) { + this(aliasName, entityClass, null, null, null, false, null); + } + + boolean isGlobal() { + return aliasName().equals("global"); + } + + boolean isPlaceholder() { + return entityClass == null; + } + + @NotNull + public SQL fetchSql() { + if (fetchSql == null) { + return SQL.noop(); + } + return switch (fetchSql.part) { + case SQL_QUERY -> fetchSql; + case AUTO_FETCH -> + SQL.query("SELECT * FROM " + getRawTableName() + " WHERE uuid = ${ref}." + dependsOnColum.column); + default -> throw new IllegalStateException("unexpected SQL definition: " + fetchSql); + }; + } + + boolean isFetchedByDirectForeignKey() { + return fetchSql != null && fetchSql.part == AUTO_FETCH; + } + + private String withoutEntitySuffix(final String simpleEntityName) { + // TODO.impl: maybe introduce an annotation like @RbacObjectName("hsOfficeContact")? + if ( simpleEntityName.endsWith("RbacEntity")) { + return simpleEntityName.substring(0, simpleEntityName.length() - "RbacEntity".length()); + } + return simpleEntityName.substring(0, simpleEntityName.length() - "Entity".length()); + } + + String simpleName() { + return isGlobal() + ? aliasName + : uncapitalize(withoutEntitySuffix(entityClass.getSimpleName())); + } + + String getRawTableName() { + if ( aliasName.equals("global")) { + return "global"; // TODO: maybe we should introduce a GlobalEntity class? + } + return withoutRvSuffix(entityClass.getAnnotation(Table.class).name()); + } + + String dependsOnColumName() { + if (dependsOnColum == null) { + throw new IllegalStateException( + "Entity " + aliasName + "(" + entityClass.getSimpleName() + ")" + ": please add dependsOnColum"); + } + return dependsOnColum.column; + } + + long level() { + return aliasName.chars().filter(ch -> ch == '.').count() + 1; + } + + boolean isCaseDependent() { + return usingCase != null && usingCase.value != null; + } + } + + public static String withoutRvSuffix(final String tableName) { + return tableName.substring(0, tableName.length() - "_rv".length()); + } + + public enum Role { + + OWNER, + ADMIN, + AGENT, + TENANT, + REFERRER, + + @Deprecated + GUEST; + + @Override + public String toString() { + return ":" + name(); + } + } + + public enum Permission { + INSERT, + DELETE, + UPDATE, + SELECT; + + @Override + public String toString() { + return ":" + name(); + } + } + + public static class SQL { + + /** + * DSL method to specify an SQL SELECT expression which fetches the related entity, + * using the reference `${ref}` of the root entity and `${columns}` for the projection. + * + *

The query must define the entity alias name of the fetched table + * as its alias for, so it can be used in the generated projection (the columns between + * `SELECT` and `FROM`.

+ * + *

`${ref}` is going to be replaced by either `NEW` or `OLD` of the trigger function. + * `into ...` will be added with a variable name prefixed with either `new` or `old`.

+ * + *

`${columns}` is going to be replaced by the columns which are needed for the query, + * e.g. `*` or `uuid`.

+ * + * @param sql an SQL SELECT expression (not ending with ';) + * @return the wrapped SQL expression + */ + public static SQL fetchedBySql(final String sql) { + if ( !sql.startsWith("SELECT ${columns}") ) { + throw new IllegalArgumentException("SQL SELECT expression must start with 'SELECT ${columns}', but is: " + sql); + } + validateExpression(sql); + return new SQL(sql, Part.SQL_QUERY); + } + + /** + * DSL method to specify that a related entity is to be fetched by a simple SELECT statement + * using the raw table from the @Table statement of the entity to fetch + * and the dependent column of the root entity. + * + * @return the wrapped SQL definition object + */ + public static SQL directlyFetchedByDependsOnColumn() { + return new SQL(null, AUTO_FETCH); + } + + /** + * DSL method to explicitly specify that there is no SQL query. + * + * @return a wrapped SQL definition object representing a noop query + */ + public static SQL noop() { + return new SQL(null, Part.NOOP); + } + + /** + * Generic DSL method to specify an SQL SELECT expression. + * + * @param sql an SQL SELECT expression (not ending with ';) + * @return the wrapped SQL expression + */ + public static SQL query(final String sql) { + validateExpression(sql); + return new SQL(sql, Part.SQL_QUERY); + } + + /** + * Generic DSL method to specify an SQL SELECT expression by just the projection part. + * + * @param projection an SQL SELECT expression, the list of columns after 'SELECT' + * @return the wrapped SQL projection + */ + public static SQL projection(final String projection) { + validateProjection(projection); + return new SQL(projection, Part.SQL_PROJECTION); + } + + public static SQL expression(final String sqlExpression) { + // TODO: validate + return new SQL(sqlExpression, Part.SQL_EXPRESSION); + } + + enum Part { + NOOP, + SQL_QUERY, + AUTO_FETCH, + SQL_PROJECTION, + SQL_EXPRESSION + } + + final String sql; + final Part part; + + private SQL(final String sql, final Part part) { + this.sql = sql; + this.part = part; + } + + private static void validateProjection(final String projection) { + if (projection.toUpperCase().matches("[ \t]*$SELECT[ \t]")) { + throw new IllegalArgumentException("SQL projection must not start with 'SELECT': " + projection); + } + if (projection.matches(";[ \t]*$")) { + throw new IllegalArgumentException("SQL projection must not end with ';': " + projection); + } + } + + private static void validateExpression(final String sql) { + if (sql.matches(";[ \t]*$")) { + throw new IllegalArgumentException("SQL expression must not end with ';': " + sql); + } + } + } + + public static class Column { + + public static Column dependsOnColumn(final String column) { + return new Column(column); + } + + public final String column; + + private Column(final String column) { + this.column = column; + } + } + + public static class ColumnValue { + + public static ColumnValue usingDefaultCase() { + return new ColumnValue(null); + } + + public static > ColumnValue usingCase(final E value) { + return new ColumnValue(value.name()); + } + public final String value; + + private ColumnValue(final String value) { + this.value = value; + } + } + + private static class AliasNameMapper { + + private final RbacView importedRbacView; + private final String outerAliasName; + + private final Set outerAliasNames; + + AliasNameMapper(final RbacView importedRbacView, final String outerAliasName, final Set outerAliasNames) { + this.importedRbacView = importedRbacView; + this.outerAliasName = outerAliasName; + this.outerAliasNames = (outerAliasNames == null) ? Collections.emptySet() : outerAliasNames; + } + + String map(final String originalAliasName) { + if (outerAliasNames.contains(originalAliasName) || originalAliasName.equals("global")) { + return originalAliasName; + } + if (originalAliasName.equals(importedRbacView.rootEntityAlias.aliasName)) { + return outerAliasName; + } + return outerAliasName + "." + originalAliasName; + } + } + + public static class CaseDef extends ColumnValue { + + final Consumer def; + + private CaseDef(final String discriminatorColumnValue, final Consumer def) { + super(discriminatorColumnValue); + this.def = def; + } + + + public static CaseDef inCaseOf(final String discriminatorColumnValue, final Consumer def) { + return new CaseDef(discriminatorColumnValue, def); + } + + public static CaseDef inOtherCases(final Consumer def) { + return new CaseDef(null, def); + } + + @Override + public int hashCode() { + return ofNullable(value).map(String::hashCode).orElse(0); + } + + @Override + public boolean equals(final Object other) { + if (this == other) + return true; + if (other == null || getClass() != other.getClass()) + return false; + final CaseDef caseDef = (CaseDef) other; + return Objects.equals(value, caseDef.value); + } + + boolean isDefaultCase() { + return value == null; + } + + @Override + public String toString() { + return isDefaultCase() + ? "inOtherCases" + : "inCaseOf:" + value; + } + + public boolean isCase(final ColumnValue requestedCase) { + return Objects.equals(requestedCase.value, this.value); + } + } + + private static void generateRbacView(final Class c) { + final Method mainMethod = stream(c.getMethods()).filter( + m -> isStatic(m.getModifiers()) && m.getName().equals("main") + ) + .findFirst() + .orElse(null); + if (mainMethod != null) { + try { + mainMethod.invoke(null, new Object[] { null }); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } else { + System.err.println("WARNING: no main method in: " + c.getName() + " => no RBAC rules generated"); + } + } + + public static Set> findRbacEntityClasses(String packageName) { + final var reflections = new Reflections(packageName, TypeAnnotationsScanner.class); + return reflections.getTypesAnnotatedWith(Entity.class).stream() + .filter(c -> stream(c.getInterfaces()).anyMatch(i -> i== BaseEntity.class)) + .filter(c -> stream(c.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals("rbac") && Modifier.isStatic(m.getModifiers())) + ) + .map(RbacView::castToSubclassOfBaseEntity) + .collect(Collectors.toSet()); + } + + @SuppressWarnings("unchecked") + private static Class castToSubclassOfBaseEntity(final Class clazz) { + return (Class) clazz; + } + + /** + * This main method generates the RbacViews (PostgreSQL+diagram) for all given entity classes. + */ + public static void main(String[] args) throws Exception { + findRbacEntityClasses("net.hostsharing.hsadminng") + .forEach(RbacView::generateRbacView); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewMermaidFlowchartGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewMermaidFlowchartGenerator.java new file mode 100644 index 00000000..a820ad6a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewMermaidFlowchartGenerator.java @@ -0,0 +1,207 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef; +import org.apache.commons.lang3.StringUtils; + +import java.nio.file.*; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.*; + +public class RbacViewMermaidFlowchartGenerator { + + public static final String HOSTSHARING_DARK_ORANGE = "#dd4901"; + public static final String HOSTSHARING_LIGHT_ORANGE = "#feb28c"; + public static final String HOSTSHARING_DARK_BLUE = "#274d6e"; + public static final String HOSTSHARING_LIGHT_BLUE = "#99bcdb"; + private final RbacView rbacDef; + + private final List usedEntityAliases; + + private final CaseDef forCase; + private final StringWriter flowchart = new StringWriter(); + + public RbacViewMermaidFlowchartGenerator(final RbacView rbacDef, final CaseDef forCase) { + this.rbacDef = rbacDef; + this.forCase = forCase; + + usedEntityAliases = rbacDef.getGrantDefs().stream() + .flatMap(g -> Stream.of( + g.getSuperRoleDef() != null ? g.getSuperRoleDef().getEntityAlias() : null, + g.getSubRoleDef() != null ? g.getSubRoleDef().getEntityAlias() : null, + g.getPermDef() != null ? g.getPermDef().getEntityAlias() : null)) + .filter(Objects::nonNull) + .sorted(comparing(RbacView.EntityAlias::aliasName)) + .distinct() + .filter(rbacDef::renderInDiagram) + .collect(Collectors.toList()); + + flowchart.writeLn(""" + %%{init:{'flowchart':{'htmlLabels':false}}}%% + flowchart TB + """); + renderEntitySubgraphs(); + renderGrants(); + } + + public RbacViewMermaidFlowchartGenerator(final RbacView rbacDef) { + this(rbacDef, null); + } + private void renderEntitySubgraphs() { + usedEntityAliases.stream() + .filter(entityAlias -> !rbacDef.isEntityAliasProxy(entityAlias)) + .filter(entityAlias -> !entityAlias.isPlaceholder()) + .filter(rbacDef::renderInDiagram) + .forEach(this::renderEntitySubgraph); + } + + private void renderEntitySubgraph(final RbacView.EntityAlias entity) { + if (!rbacDef.renderInDiagram(entity)) { + return; + } + + final var color = rbacDef.isRootEntityAlias(entity) ? HOSTSHARING_DARK_ORANGE + : entity.isSubEntity() ? HOSTSHARING_LIGHT_ORANGE + : HOSTSHARING_LIGHT_BLUE; + flowchart.writeLn(""" + subgraph %{aliasName}["`**%{aliasName}**`"] + direction TB + style %{aliasName} fill:%{fillColor},stroke:%{strokeColor},stroke-width:8px + """ + .replace("%{aliasName}", entity.aliasName()) + .replace("%{fillColor}", color ) + .replace("%{strokeColor}", HOSTSHARING_DARK_BLUE )); + + flowchart.indented( () -> { + usedEntityAliases.stream() + .filter(e -> e.aliasName().startsWith(entity.aliasName() + ":")) + .forEach(this::renderEntitySubgraph); + + wrapOutputInSubgraph(entity.aliasName() + ":roles", color, + rbacDef.getRoleDefs().stream() + .filter(r -> r.getEntityAlias() == entity) + .map(this::roleDef) + .collect(joining("\n"))); + + wrapOutputInSubgraph(entity.aliasName() + ":permissions", color, + rbacDef.getPermDefs().stream() + .filter(p -> p.getEntityAlias() == entity) + .map(this::permDef) + .collect(joining("\n"))); + + if (rbacDef.isRootEntityAlias(entity) && rbacDef.getRootEntityAliasProxy() != null ) { + renderEntitySubgraph(rbacDef.getRootEntityAliasProxy()); + } + + }); + flowchart.chopEmptyLines(); + flowchart.writeLn("end"); + flowchart.writeLn(); + } + + private void wrapOutputInSubgraph(final String name, final String color, final String content) { + if (!StringUtils.isEmpty(content)) { + flowchart.ensureSingleEmptyLine(); + flowchart.writeLn("subgraph " + name + "[ ]\n"); + flowchart.indented(() -> { + flowchart.writeLn("style %{aliasName} fill:%{fillColor},stroke:white" + .replace("%{aliasName}", name) + .replace("%{fillColor}", color)); + flowchart.writeLn(); + flowchart.writeLn(content); + }); + flowchart.chopEmptyLines(); + flowchart.writeLn("end"); + flowchart.writeLn(); + } + } + + private void renderGrants() { + renderGrants(ROLE_TO_USER, "%% granting roles to users"); + renderGrants(ROLE_TO_ROLE, "%% granting roles to roles"); + renderGrants(PERM_TO_ROLE, "%% granting permissions to roles"); + } + + private void renderGrants(final RbacView.RbacGrantDefinition.GrantType grantType, final String comment) { + final var grantsOfRequestedType = rbacDef.getGrantDefs().stream() + .filter(g -> g.grantType() == grantType) + .filter(rbacDef::renderInDiagram) + .filter(this::isToBeRenderedForThisCase) + .toList(); + if ( !grantsOfRequestedType.isEmpty()) { + flowchart.ensureSingleEmptyLine(); + flowchart.writeLn(comment); + grantsOfRequestedType.forEach(g -> flowchart.writeLn(grantDef(g))); + } + } + + private boolean isToBeRenderedForThisCase(final RbacView.RbacGrantDefinition g) { + if ( g.grantType() == ROLE_TO_USER ) + return true; + if ( forCase == null && !g.isConditional() ) + return true; + final var isToBeRenderedInThisGraph = g.getForCases() == null || g.getForCases().contains(forCase); + return isToBeRenderedInThisGraph; + } + + private String grantDef(final RbacView.RbacGrantDefinition grant) { + final var arrow = (grant.isToCreate() ? " ==>" : " -.->") + + (grant.isAssumed() ? " " : "|XX| "); + final var grantDef = switch (grant.grantType()) { + case ROLE_TO_USER -> + // TODO: other user types not implemented yet + "user:creator" + arrow + roleId(grant.getSubRoleDef()); + case ROLE_TO_ROLE -> + roleId(grant.getSuperRoleDef()) + arrow + roleId(grant.getSubRoleDef()); + case PERM_TO_ROLE -> roleId(grant.getSuperRoleDef()) + arrow + permId(grant.getPermDef()); + }; + return grantDef; + } + + private String permDef(final RbacView.RbacPermissionDefinition perm) { + return permId(perm) + "{{" + perm.getEntityAlias().aliasName() + perm.getPermission() + "}}"; + } + + private static String permId(final RbacView.RbacPermissionDefinition permDef) { + return "perm:" + permDef.getEntityAlias().aliasName() + permDef.getPermission(); + } + + private String roleDef(final RbacView.RbacRoleDefinition roleDef) { + return roleId(roleDef) + "[[" + roleDef.getEntityAlias().aliasName() + roleDef.getRole() + "]]"; + } + + private static String roleId(final RbacView.RbacRoleDefinition r) { + return "role:" + r.getEntityAlias().aliasName() + r.getRole(); + } + + @Override + public String toString() { + return flowchart.toString(); + } + + @SneakyThrows + public void generateToMarkdownFile(final Path path) { + Files.writeString( + path, + """ + ### rbac %{entityAlias}%{case} + + This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + + ```mermaid + %{flowchart} + ``` + """ + .replace("%{entityAlias}", rbacDef.getRootEntityAlias().aliasName()) + .replace("%{flowchart}", flowchart.toString()) + .replace("%{case}", forCase == null ? "" : " " + forCase), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + System.out.println("Markdown-File: " + path.toAbsolutePath()); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewPostgresGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewPostgresGenerator.java new file mode 100644 index 00000000..5a3b2be8 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RbacViewPostgresGenerator.java @@ -0,0 +1,53 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import lombok.SneakyThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.NEW; +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; + +public class RbacViewPostgresGenerator { + + private final RbacView rbacDef; + private final String liqibaseTagPrefix; + private final StringWriter plPgSql = new StringWriter(); + + public RbacViewPostgresGenerator(final RbacView forRbacDef) { + rbacDef = forRbacDef; + liqibaseTagPrefix = rbacDef.getRootEntityAlias().getRawTableName().replace("_", "-"); + plPgSql.writeLn(""" + --liquibase formatted sql + -- This code generated was by ${generator}, do not amend manually. + """, + with("generator", getClass().getSimpleName()), + with("ref", NEW.name())); + + new RbacObjectGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + new RbacRoleDescriptorsGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + new RolesGrantsAndPermissionsGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + new InsertTriggerGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + new RbacIdentityViewGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + new RbacRestrictedViewGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql); + } + + @Override + public String toString() { + return plPgSql.toString() + .replace("\n\n\n", "\n\n") + .replace("-- ====", "\n-- ====") + .replace("\n\n--//", "\n--//"); + } + + @SneakyThrows + public void generateToChangeLog(final Path outputPath) { + Files.writeString( + outputPath, + toString(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + System.out.println(outputPath.toAbsolutePath()); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RolesGrantsAndPermissionsGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RolesGrantsAndPermissionsGenerator.java new file mode 100644 index 00000000..2089d4d9 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/RolesGrantsAndPermissionsGenerator.java @@ -0,0 +1,608 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacPermissionDefinition; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toSet; +import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.NEW; +import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.OLD; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with; +import static org.apache.commons.lang3.StringUtils.capitalize; +import static org.apache.commons.lang3.StringUtils.uncapitalize; + +class RolesGrantsAndPermissionsGenerator { + + private final RbacView rbacDef; + private final Set rbacGrants = new HashSet<>(); + private final String liquibaseTagPrefix; + private final String simpleEntityName; + private final String simpleEntityVarName; + private final String rawTableName; + + RolesGrantsAndPermissionsGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) { + this.rbacDef = rbacDef; + this.rbacGrants.addAll(rbacDef.getGrantDefs().stream() + .filter(RbacGrantDefinition::isToCreate) + .collect(toSet())); + this.liquibaseTagPrefix = liquibaseTagPrefix; + + simpleEntityVarName = rbacDef.getRootEntityAlias().simpleName(); + simpleEntityName = capitalize(simpleEntityVarName); + rawTableName = rbacDef.getRootEntityAlias().getRawTableName(); + } + + void generateTo(final StringWriter plPgSql) { + generateInsertTrigger(plPgSql); + if (hasAnyUpdatableEntityAliases()) { + generateUpdateTrigger(plPgSql); + } + } + + private void generateHeader(final StringWriter plPgSql, final String triggerType) { + plPgSql.writeLn(""" + -- ============================================================================ + --changeset ${liquibaseTagPrefix}-rbac-${triggerType}-trigger:1 endDelimiter:--// + -- ---------------------------------------------------------------------------- + """, + with("liquibaseTagPrefix", liquibaseTagPrefix), + with("triggerType", triggerType)); + } + + private void generateInsertTriggerFunction(final StringWriter plPgSql) { + plPgSql.writeLn(""" + /* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + + create or replace procedure buildRbacSystemFor${simpleEntityName}( + NEW ${rawTableName} + ) + language plpgsql as $$ + """ + .replace("${simpleEntityName}", simpleEntityName) + .replace("${rawTableName}", rawTableName)); + + plPgSql.writeLn("declare"); + plPgSql.indented(() -> { + referencedEntityAliases() + .forEach((ea) -> plPgSql.writeLn(entityRefVar(NEW, ea) + " " + ea.getRawTableName() + ";")); + }); + + plPgSql.writeLn(); + plPgSql.writeLn("begin"); + plPgSql.indented(() -> { + plPgSql.writeLn("call enterTriggerForObjectUuid(NEW.uuid);"); + plPgSql.writeLn(); + generateCreateRolesAndGrantsAfterInsert(plPgSql); + plPgSql.ensureSingleEmptyLine(); + plPgSql.writeLn("call leaveTriggerForObjectUuid(NEW.uuid);"); + }); + plPgSql.writeLn("end; $$;"); + plPgSql.writeLn(); + } + + + private void generateSimplifiedUpdateTriggerFunction(final StringWriter plPgSql) { + + final var updateConditions = updatableEntityAliases() + .map(RbacView.EntityAlias::dependsOnColumName) + .distinct() + .map(columnName -> "NEW." + columnName + " is distinct from OLD." + columnName) + .collect(joining( "\n or ")); + plPgSql.writeLn(""" + /* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + + create or replace procedure updateRbacRulesFor${simpleEntityName}( + OLD ${rawTableName}, + NEW ${rawTableName} + ) + language plpgsql as $$ + begin + + if ${updateConditions} then + delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid; + call buildRbacSystemFor${simpleEntityName}(NEW); + end if; + end; $$; + """, + with("simpleEntityName", simpleEntityName), + with("rawTableName", rawTableName), + with("updateConditions", updateConditions)); + } + + private void generateUpdateTriggerFunction(final StringWriter plPgSql) { + plPgSql.writeLn(""" + /* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + + create or replace procedure updateRbacRulesFor${simpleEntityName}( + OLD ${rawTableName}, + NEW ${rawTableName} + ) + language plpgsql as $$ + + declare + """ + .replace("${simpleEntityName}", simpleEntityName) + .replace("${rawTableName}", rawTableName)); + + plPgSql.chopEmptyLines(); + plPgSql.indented(() -> { + referencedEntityAliases() + .forEach((ea) -> { + plPgSql.writeLn(entityRefVar(OLD, ea) + " " + ea.getRawTableName() + ";"); + plPgSql.writeLn(entityRefVar(NEW, ea) + " " + ea.getRawTableName() + ";"); + }); + }); + + plPgSql.writeLn(); + plPgSql.writeLn("begin"); + plPgSql.indented(() -> { + plPgSql.writeLn("call enterTriggerForObjectUuid(NEW.uuid);"); + plPgSql.writeLn(); + generateUpdateRolesAndGrantsAfterUpdate(plPgSql); + plPgSql.ensureSingleEmptyLine(); + plPgSql.writeLn("call leaveTriggerForObjectUuid(NEW.uuid);"); + }); + plPgSql.writeLn("end; $$;"); + plPgSql.writeLn(); + } + + private boolean hasAnyUpdatableEntityAliases() { + return updatableEntityAliases().anyMatch(e -> true); + } + + private boolean hasAnyUpdatableAndNullableEntityAliases() { + return updatableEntityAliases() + .filter(ea -> ea.nullable() == RbacView.Nullable.NULLABLE) + .anyMatch(e -> true); + } + + private boolean hasAnyConditionalGrants() { + return rbacDef.getGrantDefs().stream().anyMatch(RbacGrantDefinition::isConditional); + } + + private void generateCreateRolesAndGrantsAfterInsert(final StringWriter plPgSql) { + referencedEntityAliases() + .forEach((ea) -> { + generateFetchedVars(plPgSql, ea, NEW); + plPgSql.writeLn(); + }); + + createRolesWithGrantsSql(plPgSql, OWNER); + createRolesWithGrantsSql(plPgSql, ADMIN); + createRolesWithGrantsSql(plPgSql, AGENT); + createRolesWithGrantsSql(plPgSql, TENANT); + createRolesWithGrantsSql(plPgSql, REFERRER); + + generateGrants(plPgSql, ROLE_TO_USER); + + generateGrants(plPgSql, ROLE_TO_ROLE); + if (!rbacDef.getAllCases().isEmpty()) { + plPgSql.writeLn(); + final var ifOrElsIf = new AtomicReference<>("IF "); + rbacDef.getAllCases().forEach(caseDef -> { + if (caseDef.value != null) { + plPgSql.writeLn(ifOrElsIf + "NEW." + rbacDef.getDiscriminatorColumName() + " = '" + caseDef.value + "' THEN"); + } else { + plPgSql.writeLn("ELSE"); + } + plPgSql.indented(() -> { + generateGrants(plPgSql, ROLE_TO_ROLE, caseDef); + }); + ifOrElsIf.set("ELSIF "); + }); + plPgSql.writeLn("END IF;"); + } + + generateGrants(plPgSql, PERM_TO_ROLE); + } + + private Stream referencedEntityAliases() { + return rbacDef.getEntityAliases().values().stream() + .filter(ea -> !rbacDef.isRootEntityAlias(ea)) + .filter(ea -> ea.dependsOnColum() != null) + .filter(ea -> ea.entityClass() != null) + .filter(ea -> ea.fetchSql() != null); + } + + private Stream updatableEntityAliases() { + return referencedEntityAliases() + .filter(ea -> rbacDef.getUpdatableColumns().contains(ea.dependsOnColum().column)); + } + + private void generateUpdateRolesAndGrantsAfterUpdate(final StringWriter plPgSql) { + plPgSql.ensureSingleEmptyLine(); + + referencedEntityAliases() + .forEach((ea) -> { + generateFetchedVars(plPgSql, ea, OLD); + generateFetchedVars(plPgSql, ea, NEW); + plPgSql.writeLn(); + }); + + updatableEntityAliases() + .map(RbacView.EntityAlias::dependsOnColum) + .map(c -> c.column) + .sorted() + .distinct() + .forEach(columnName -> { + plPgSql.writeLn(); + plPgSql.writeLn("if NEW." + columnName + " <> OLD." + columnName + " then"); + plPgSql.indented(() -> { + updateGrantsDependingOn(plPgSql, columnName); + }); + plPgSql.writeLn("end if;"); + }); + } + + private void generateFetchedVars( + final StringWriter plPgSql, + final RbacView.EntityAlias ea, + final PostgresTriggerReference old) { + plPgSql.writeLn( + ea.fetchSql().sql + " INTO " + entityRefVar(old, ea) + ";", + with("columns", ea.aliasName() + ".*"), + with("ref", old.name())); + if (ea.nullable() == RbacView.Nullable.NOT_NULL) { + plPgSql.writeLn( + "assert ${entityRefVar}.uuid is not null, format('${entityRefVar} must not be null for ${REF}.${dependsOnColumn} = %s', ${REF}.${dependsOnColumn});", + with("entityRefVar", entityRefVar(old, ea)), + with("dependsOnColumn", ea.dependsOnColumName()), + with("ref", old.name())); + plPgSql.writeLn(); + } + } + + private void updateGrantsDependingOn(final StringWriter plPgSql, final String columnName) { + rbacDef.getGrantDefs().stream() + .filter(RbacGrantDefinition::isToCreate) + .filter(g -> g.dependsOnColumn(columnName)) + .filter(g -> !isInsertPermissionGrant(g)) + .forEach(g -> { + plPgSql.ensureSingleEmptyLine(); + plPgSql.writeLn(generateRevoke(g)); + plPgSql.writeLn(generateGrant(g)); + plPgSql.writeLn(); + }); + } + + private static Boolean isInsertPermissionGrant(final RbacGrantDefinition g) { + final var isInsertPermissionGrant = ofNullable(g.getPermDef()).map(RbacPermissionDefinition::getPermission).map(p -> p == INSERT).orElse(false); + return isInsertPermissionGrant; + } + + private void generateGrants(final StringWriter plPgSql, final RbacGrantDefinition.GrantType grantType, final CaseDef caseDef) { + rbacGrants.stream() + .filter(g -> g.matchesCase(caseDef)) + .filter(g -> g.grantType() == grantType) + .map(this::generateGrant) + .sorted() + .forEach(text -> plPgSql.writeLn(text, with("ref", NEW.name()))); + } + + private void generateGrants(final StringWriter plPgSql, final RbacGrantDefinition.GrantType grantType) { + plPgSql.ensureSingleEmptyLine(); + rbacGrants.stream() + .filter(g -> !g.isConditional()) + .filter(g -> g.grantType() == grantType) + .map(this::generateGrant) + .sorted() + .forEach(text -> plPgSql.writeLn(text, with("ref", NEW.name()))); + } + + private String generateRevoke(RbacGrantDefinition grantDef) { + return switch (grantDef.grantType()) { + case ROLE_TO_USER -> throw new IllegalArgumentException("unexpected grant"); + case ROLE_TO_ROLE -> "call revokeRoleFromRole(${subRoleRef}, ${superRoleRef});" + .replace("${subRoleRef}", roleRef(OLD, grantDef.getSubRoleDef())) + .replace("${superRoleRef}", roleRef(OLD, grantDef.getSuperRoleDef())); + case PERM_TO_ROLE -> "call revokePermissionFromRole(${permRef}, ${superRoleRef});" + .replace("${permRef}", getPerm(OLD, grantDef.getPermDef())) + .replace("${superRoleRef}", roleRef(OLD, grantDef.getSuperRoleDef())); + }; + } + + private String generateGrant(RbacGrantDefinition grantDef) { + final var grantSql = switch (grantDef.grantType()) { + case ROLE_TO_USER -> throw new IllegalArgumentException("unexpected grant"); + case ROLE_TO_ROLE -> "call grantRoleToRole(${subRoleRef}, ${superRoleRef}${assumed});" + .replace("${assumed}", grantDef.isAssumed() ? "" : ", unassumed()") + .replace("${subRoleRef}", roleRef(NEW, grantDef.getSubRoleDef())) + .replace("${superRoleRef}", roleRef(NEW, grantDef.getSuperRoleDef())); + case PERM_TO_ROLE -> + grantDef.getPermDef().getPermission() == INSERT ? "" + : "call grantPermissionToRole(${permRef}, ${superRoleRef});" + .replace("${permRef}", createPerm(NEW, grantDef.getPermDef())) + .replace("${superRoleRef}", roleRef(NEW, grantDef.getSuperRoleDef())); + }; + return grantSql; + } + + private String findPerm(final PostgresTriggerReference ref, final RbacPermissionDefinition permDef) { + return permRef("findPermissionId", ref, permDef); + } + + private String getPerm(final PostgresTriggerReference ref, final RbacPermissionDefinition permDef) { + return permRef("getPermissionId", ref, permDef); + } + + private String createPerm(final PostgresTriggerReference ref, final RbacPermissionDefinition permDef) { + return permRef("createPermission", ref, permDef); + } + + private String permRef(final String functionName, final PostgresTriggerReference ref, final RbacPermissionDefinition permDef) { + return "${prefix}(${entityRef}.uuid, '${perm}')" + .replace("${prefix}", functionName) + .replace("${entityRef}", rbacDef.isRootEntityAlias(permDef.entityAlias) + ? ref.name() + : refVarName(ref, permDef.entityAlias)) + .replace("${perm}", permDef.permission.name()); + } + + private String refVarName(final PostgresTriggerReference ref, final RbacView.EntityAlias entityAlias) { + return ref.name().toLowerCase() + capitalize(entityAlias.aliasName()); + } + + private String roleRef(final PostgresTriggerReference rootRefVar, final RbacView.RbacRoleDefinition roleDef) { + if (roleDef == null) { + System.out.println("null"); + } + if (roleDef.getEntityAlias().isGlobal()) { + return "globalAdmin()"; + } + final String entityRefVar = entityRefVar(rootRefVar, roleDef.getEntityAlias()); + return roleDef.getEntityAlias().simpleName() + capitalize(roleDef.getRole().name()) + + "(" + entityRefVar + ")"; + } + + private String entityRefVar( + final PostgresTriggerReference rootRefVar, + final RbacView.EntityAlias entityAlias) { + return rbacDef.isRootEntityAlias(entityAlias) + ? rootRefVar.name() + : rootRefVar.name().toLowerCase() + capitalize(entityAlias.aliasName()); + } + + private void createRolesWithGrantsSql(final StringWriter plPgSql, final RbacView.Role role) { + + final var isToCreate = rbacDef.getRoleDefs().stream() + .filter(roleDef -> rbacDef.isRootEntityAlias(roleDef.getEntityAlias()) && roleDef.getRole() == role) + .findFirst().map(RbacView.RbacRoleDefinition::isToCreate).orElse(false); + if (!isToCreate) { + return; + } + + plPgSql.writeLn(); + plPgSql.writeLn("perform createRoleWithGrants("); + plPgSql.indented(() -> { + plPgSql.writeLn("${simpleVarName)${roleSuffix}(NEW)," + .replace("${simpleVarName)", simpleEntityVarName) + .replace("${roleSuffix}", capitalize(role.name()))); + + generatePermissionsForRole(plPgSql, role); + generateIncomingSuperRolesForRole(plPgSql, role); + generateOutgoingSubRolesForRole(plPgSql, role); + generateUserGrantsForRole(plPgSql, role); + + plPgSql.chopTail(",\n"); + plPgSql.writeLn(); + }); + + plPgSql.writeLn(");"); + } + + private void generateUserGrantsForRole(final StringWriter plPgSql, final RbacView.Role role) { + final var grantsToUsers = findGrantsToUserForRole(rbacDef.getRootEntityAlias(), role); + if (!grantsToUsers.isEmpty()) { + final var arrayElements = grantsToUsers.stream() + .map(RbacGrantDefinition::getUserDef) + .map(this::toPlPgSqlReference) + .toList(); + plPgSql.indented(() -> + plPgSql.writeLn("userUuids => array[" + joinArrayElements(arrayElements, 2) + "],\n")); + rbacGrants.removeAll(grantsToUsers); + } + } + + private void generatePermissionsForRole(final StringWriter plPgSql, final RbacView.Role role) { + final var permissionGrantsForRole = findPermissionsGrantsForRole(rbacDef.getRootEntityAlias(), role); + if (!permissionGrantsForRole.isEmpty()) { + final var arrayElements = permissionGrantsForRole.stream() + .map(RbacGrantDefinition::getPermDef) + .map(RbacPermissionDefinition::getPermission) + .map(RbacView.Permission::name) + .map(p -> "'" + p + "'") + .sorted() + .toList(); + plPgSql.indented(() -> + plPgSql.writeLn("permissions => array[" + joinArrayElements(arrayElements, 3) + "],\n")); + rbacGrants.removeAll(permissionGrantsForRole); + } + } + + private void generateIncomingSuperRolesForRole(final StringWriter plPgSql, final RbacView.Role role) { + final var unconditionalIncomingGrants = findIncomingSuperRolesForRole(rbacDef.getRootEntityAlias(), role).stream() + .filter(g -> !g.isConditional()) + .toList(); + if (!unconditionalIncomingGrants.isEmpty()) { + final var arrayElements = unconditionalIncomingGrants.stream() + .map(g -> toPlPgSqlReference(NEW, g.getSuperRoleDef(), g.isAssumed())) + .sorted().toList(); + plPgSql.indented(() -> + plPgSql.writeLn("incomingSuperRoles => array[" + joinArrayElements(arrayElements, 1) + "],\n")); + rbacGrants.removeAll(unconditionalIncomingGrants); + } + } + + private void generateOutgoingSubRolesForRole(final StringWriter plPgSql, final RbacView.Role role) { + final var unconditionalOutgoingGrants = findOutgoingSuperRolesForRole(rbacDef.getRootEntityAlias(), role).stream() + .filter(g -> !g.isConditional()) + .toList(); + if (!unconditionalOutgoingGrants.isEmpty()) { + final var arrayElements = unconditionalOutgoingGrants.stream() + .map(g -> toPlPgSqlReference(NEW, g.getSubRoleDef(), g.isAssumed())) + .sorted().toList(); + plPgSql.indented(() -> + plPgSql.writeLn("outgoingSubRoles => array[" + joinArrayElements(arrayElements, 1) + "],\n")); + rbacGrants.removeAll(unconditionalOutgoingGrants); + } + } + + private String joinArrayElements(final List arrayElements, final int singleLineLimit) { + return arrayElements.size() <= singleLineLimit + ? String.join(", ", arrayElements) + : arrayElements.stream().collect(joining(",\n\t", "\n\t", "")); + } + + private Set findPermissionsGrantsForRole( + final RbacView.EntityAlias entityAlias, + final RbacView.Role role) { + final var roleDef = rbacDef.findRbacRole(entityAlias, role); + return rbacGrants.stream() + .filter(g -> g.grantType() == PERM_TO_ROLE && g.getSuperRoleDef() == roleDef) + .collect(toSet()); + } + + private Set findGrantsToUserForRole( + final RbacView.EntityAlias entityAlias, + final RbacView.Role role) { + final var roleDef = rbacDef.findRbacRole(entityAlias, role); + return rbacGrants.stream() + .filter(g -> g.grantType() == ROLE_TO_USER && g.getSubRoleDef() == roleDef) + .collect(toSet()); + } + + private Set findIncomingSuperRolesForRole( + final RbacView.EntityAlias entityAlias, + final RbacView.Role role) { + final var roleDef = rbacDef.findRbacRole(entityAlias, role); + return rbacGrants.stream() + .filter(g -> g.grantType() == ROLE_TO_ROLE && g.getSubRoleDef() == roleDef) + .collect(toSet()); + } + + private Set findOutgoingSuperRolesForRole( + final RbacView.EntityAlias entityAlias, + final RbacView.Role role) { + final var roleDef = rbacDef.findRbacRole(entityAlias, role); + return rbacGrants.stream() + .filter(g -> g.grantType() == ROLE_TO_ROLE && g.getSuperRoleDef() == roleDef) + .filter(g -> g.getSubRoleDef().getEntityAlias() != entityAlias) + .collect(toSet()); + } + + private void generateInsertTrigger(final StringWriter plPgSql) { + + generateHeader(plPgSql, "insert"); + generateInsertTriggerFunction(plPgSql); + + plPgSql.writeLn(""" + /* + AFTER INSERT TRIGGER to create the role+grant structure for a new ${rawTableName} row. + */ + + create or replace function insertTriggerFor${simpleEntityName}_tf() + returns trigger + language plpgsql + strict as $$ + begin + call buildRbacSystemFor${simpleEntityName}(NEW); + return NEW; + end; $$; + + create trigger insertTriggerFor${simpleEntityName}_tg + after insert on ${rawTableName} + for each row + execute procedure insertTriggerFor${simpleEntityName}_tf(); + """ + .replace("${simpleEntityName}", simpleEntityName) + .replace("${rawTableName}", rawTableName) + ); + + generateFooter(plPgSql); + } + + private void generateUpdateTrigger(final StringWriter plPgSql) { + + generateHeader(plPgSql, "update"); + if ( hasAnyUpdatableAndNullableEntityAliases() || hasAnyConditionalGrants() ) { + generateSimplifiedUpdateTriggerFunction(plPgSql); + } else { + generateUpdateTriggerFunction(plPgSql); + } + + plPgSql.writeLn(""" + /* + AFTER INSERT TRIGGER to re-wire the grant structure for a new ${rawTableName} row. + */ + + create or replace function updateTriggerFor${simpleEntityName}_tf() + returns trigger + language plpgsql + strict as $$ + begin + call updateRbacRulesFor${simpleEntityName}(OLD, NEW); + return NEW; + end; $$; + + create trigger updateTriggerFor${simpleEntityName}_tg + after update on ${rawTableName} + for each row + execute procedure updateTriggerFor${simpleEntityName}_tf(); + """ + .replace("${simpleEntityName}", simpleEntityName) + .replace("${rawTableName}", rawTableName) + ); + + generateFooter(plPgSql); + } + + private static void generateFooter(final StringWriter plPgSql) { + plPgSql.writeLn("--//"); + plPgSql.writeLn(); + } + + private String toPlPgSqlReference(final RbacView.RbacUserReference userRef) { + return switch (userRef.role) { + case CREATOR -> "currentUserUuid()"; + default -> throw new IllegalArgumentException("unknown user role: " + userRef); + }; + } + + private String toPlPgSqlReference( + final PostgresTriggerReference triggerRef, + final RbacView.RbacRoleDefinition roleDef, + final boolean assumed) { + final var assumedArg = assumed ? "" : ", unassumed()"; + return toRoleRef(roleDef) + + (roleDef.getEntityAlias().isGlobal() ? ( assumed ? "()" : "(unassumed())") + : rbacDef.isRootEntityAlias(roleDef.getEntityAlias()) ? ("(" + triggerRef.name() + ")") + : "(" + toTriggerReference(triggerRef, roleDef.getEntityAlias()) + assumedArg + ")"); + } + + private static String toRoleRef(final RbacView.RbacRoleDefinition roleDef) { + return uncapitalize(roleDef.getEntityAlias().simpleName()) + capitalize(roleDef.getRole().name()); + } + + private static String toTriggerReference( + final PostgresTriggerReference triggerRef, + final RbacView.EntityAlias entityAlias) { + return triggerRef.name().toLowerCase() + capitalize(entityAlias.aliasName()); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/StringWriter.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/StringWriter.java new file mode 100644 index 00000000..d78e9a3b --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/StringWriter.java @@ -0,0 +1,121 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +import org.apache.commons.lang3.StringUtils; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; + +public class StringWriter { + + private final StringBuilder string = new StringBuilder(); + private int indentLevel = 0; + + static VarDef with(final String var, final String name) { + return new VarDef(var, name); + } + + void writeLn(final String text) { + string.append( indented(text)); + writeLn(); + } + + void writeLn(final String text, final VarDef... varDefs) { + string.append( indented( new VarReplacer(varDefs).apply(text) )); + writeLn(); + } + + void writeLn() { + string.append( "\n"); + } + + void indent() { + ++indentLevel; + } + + void unindent() { + --indentLevel; + } + + void indent(int levels) { + indentLevel += levels; + } + + void unindent(int levels) { + indentLevel -= levels; + } + + void indented(final Runnable indented) { + indent(); + indented.run(); + unindent(); + } + + void indented(int levels, final Runnable indented) { + indent(levels); + indented.run(); + unindent(levels); + } + + boolean chopTail(final String tail) { + if (string.toString().endsWith(tail)) { + string.setLength(string.length() - tail.length()); + return true; + } + return false; + } + + void chopEmptyLines() { + while (string.toString().endsWith("\n\n")) { + string.setLength(string.length() - 1); + }; + } + + void ensureSingleEmptyLine() { + chopEmptyLines(); + writeLn(); + } + + @Override + public String toString() { + return string.toString(); + } + + public static String indented(final int indentLevel, final String text) { + final var indentation = StringUtils.repeat(" ", indentLevel); + final var indented = stream(text.split("\n")) + .map(line -> line.trim().isBlank() ? "" : indentation + line) + .collect(joining("\n")); + return indented; + } + + private String indented(final String text) { + if ( indentLevel == 0) { + return text; + } + return indented(indentLevel, text); + } + + record VarDef(String name, String value){} + + private static final class VarReplacer { + + private final VarDef[] varDefs; + private String text; + + private VarReplacer(VarDef[] varDefs) { + this.varDefs = varDefs; + } + + String apply(final String textToAppend) { + text = textToAppend; + stream(varDefs).forEach(varDef -> { + // TODO.impl: I actually want a case-independent search+replace but ... + // for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs. + text = text.replace("${" + varDef.name() + "}", varDef.value()); + text = text.replace("${" + varDef.name().toUpperCase() + "}", varDef.value()); + text = text.replace("${" + varDef.name().toLowerCase() + "}", varDef.value()); + }); + return text; + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/package-info.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/package-info.java new file mode 100644 index 00000000..2a193f2f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/package-info.java @@ -0,0 +1,5 @@ +package net.hostsharing.hsadminng.rbac.rbacdef; + +// TODO: The whole code in this package is more like a quick hack to solve an urgent problem. +// It should be re-written in PostgreSQL pl/pgsql, +// so that no Java is needed to use this RBAC system in it's full extend. diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java similarity index 79% rename from src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java rename to src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java index bd1c8f41..f7b3cdf4 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java @@ -1,16 +1,15 @@ package net.hostsharing.hsadminng.rbac.rbacgrant; import lombok.*; -import org.jetbrains.annotations.NotNull; import org.springframework.data.annotation.Immutable; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; @Entity @Table(name = "rbacgrants_ev") @@ -21,7 +20,7 @@ import java.util.stream.Collectors; @Immutable @NoArgsConstructor @AllArgsConstructor -public class RawRbacGrantEntity { +public class RawRbacGrantEntity implements Comparable { @Id private UUID uuid; @@ -61,7 +60,13 @@ public class RawRbacGrantEntity { @NotNull - public static List grantDisplaysOf(final List roles) { - return roles.stream().map(RawRbacGrantEntity::toDisplay).collect(Collectors.toList()); + public static List distinctGrantDisplaysOf(final List roles) { + // TODO: remove .distinct() once partner.person + partner.contact are removed + return roles.stream().map(RawRbacGrantEntity::toDisplay).sorted().distinct().toList(); + } + + @Override + public int compareTo(final Object o) { + return uuid.compareTo(((RawRbacGrantEntity)o).uuid); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java similarity index 67% rename from src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java rename to src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java index c7ac60ab..37828bdf 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java @@ -8,4 +8,8 @@ import java.util.UUID; public interface RawRbacGrantRepository extends Repository { List findAll(); + + List findByAscendingUuid(UUID ascendingUuid); + + List findByDescendantUuid(UUID refUuid); } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java index 29bdc2d8..9dfaea74 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java @@ -94,4 +94,17 @@ public class RbacGrantController implements RbacGrantsApi { return ResponseEntity.noContent().build(); } + +// TODO: implement an endpoint to create a Mermaid flowchart with all grants of a given user +// @GetMapping( +// path = "/api/rbac/users/{userUuid}/grants", +// produces = {"text/vnd.mermaid"}) +// @Transactional(readOnly = true) +// public ResponseEntity allGrantsOfUserAsMermaid( +// @RequestHeader(name = "current-user") String currentUser, +// @RequestHeader(name = "assumed-roles", required = false) String assumedRoles) { +// final var graph = RbacGrantsDiagramService.allGrantsToUser(currentUser); +// return ResponseEntity.ok(graph); +// } + } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java index a3abf528..c2f2d524 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java @@ -59,9 +59,9 @@ public class RbacGrantEntity { } public String toDisplay() { - return "{ grant role " + grantedRoleIdName + - " to user " + granteeUserName + - " by role " + grantedByRoleIdName + + return "{ grant role:" + grantedRoleIdName + + " to user:" + granteeUserName + + " by role:" + grantedByRoleIdName + (assumed ? " and assume" : "") + " }"; } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java index f385d69b..90cf0e58 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java @@ -15,6 +15,8 @@ public interface RbacGrantRepository extends Repository findAll(); RbacGrantEntity save(final RbacGrantEntity grant); diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java new file mode 100644 index 00000000..f1369067 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java @@ -0,0 +1,238 @@ +package net.hostsharing.hsadminng.rbac.rbacgrant; + +import net.hostsharing.hsadminng.context.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.validation.constraints.NotNull; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.*; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService.Include.*; + +// TODO: cleanup - this code was 'hacked' to quickly fix a specific problem, needs refactoring +@Service +public class RbacGrantsDiagramService { + + private static final int GRANT_LIMIT = 500; + + public static void writeToFile(final String title, final String graph, final String fileName) { + + new File("doc/temp").mkdirs(); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) { + writer.write(""" + ### all grants to %s + + ```mermaid + %s + ``` + """.formatted(title, graph)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public enum Include { + DETAILS, + USERS, + PERMISSIONS, + NOT_ASSUMED, + TEST_ENTITIES, + NON_TEST_ENTITIES; + + public static final EnumSet ALL = EnumSet.allOf(Include.class); + public static final EnumSet ALL_TEST_ENTITY_RELATED = EnumSet.of(USERS, DETAILS, NOT_ASSUMED, TEST_ENTITIES, PERMISSIONS); + public static final EnumSet ALL_NON_TEST_ENTITY_RELATED = EnumSet.of(USERS, DETAILS, NOT_ASSUMED, NON_TEST_ENTITIES, PERMISSIONS); + } + + @Autowired + private Context context; + + @Autowired + private RawRbacGrantRepository rawGrantRepo; + + @PersistenceContext + private EntityManager em; + + private Map> descendantsByUuid = new HashMap<>(); + + public String allGrantsToCurrentUser(final EnumSet includes) { + final var graph = new LimitedHashSet(); + for ( UUID subjectUuid: context.currentSubjectsUuids() ) { + traverseGrantsTo(graph, subjectUuid, includes); + } + return toMermaidFlowchart(graph, includes); + } + + private void traverseGrantsTo(final Set graph, final UUID refUuid, final EnumSet includes) { + final var grants = rawGrantRepo.findByAscendingUuid(refUuid); + grants.forEach(g -> { + if (!includes.contains(PERMISSIONS) && g.getDescendantIdName().startsWith("perm:")) { + return; + } + if ( !g.getDescendantIdName().startsWith("role:global")) { + if (!includes.contains(TEST_ENTITIES) && g.getDescendantIdName().contains(":test_")) { + return; + } + if (!includes.contains(NON_TEST_ENTITIES) && !g.getDescendantIdName().contains(":test_")) { + return; + } + } + graph.add(g); + if (includes.contains(NOT_ASSUMED) || g.isAssumed()) { + traverseGrantsTo(graph, g.getDescendantUuid(), includes); + } + }); + } + + public String allGrantsFrom(final UUID targetObject, final String op, final EnumSet includes) { + final var refUuid = (UUID) em.createNativeQuery("SELECT uuid FROM rbacpermission WHERE objectuuid=:targetObject AND op=:op") + .setParameter("targetObject", targetObject) + .setParameter("op", op) + .getSingleResult(); + final var graph = new LimitedHashSet(); + traverseGrantsFrom(graph, refUuid, includes); + return toMermaidFlowchart(graph, includes); + } + + private void traverseGrantsFrom(final Set graph, final UUID refUuid, final EnumSet option) { + final var grants = findDescendantsByUuid(refUuid); + grants.forEach(g -> { + if (!option.contains(USERS) && g.getAscendantIdName().startsWith("user:")) { + return; + } + graph.add(g); + if (option.contains(NOT_ASSUMED) || g.isAssumed()) { + traverseGrantsFrom(graph, g.getAscendingUuid(), option); + } + }); + } + + private List findDescendantsByUuid(final UUID refUuid) { + // TODO.impl: if that UUID already got processed, do we need to return anything at all? + return descendantsByUuid.computeIfAbsent(refUuid, uuid -> rawGrantRepo.findByDescendantUuid(uuid)); + } + + private String toMermaidFlowchart(final HashSet graph, final EnumSet includes) { + final var entities = + includes.contains(DETAILS) + ? graph.stream() + .flatMap(g -> Stream.of( + new Node(g.getAscendantIdName(), g.getAscendingUuid()), + new Node(g.getDescendantIdName(), g.getDescendantUuid())) + ) + .collect(groupingBy(RbacGrantsDiagramService::renderEntityIdName)) + .entrySet().stream() + .map(entity -> "subgraph " + cleanId(entity.getKey()) + renderSubgraph(entity.getKey()) + "\n\n " + + entity.getValue().stream() + .map(n -> renderNode(n.idName(), n.uuid()).replace("\n", "\n ")) + .sorted() + .distinct() + .collect(joining("\n\n "))) + .collect(joining("\n\nend\n\n")) + + "\n\nend\n\n" + : ""; + + final var grants = graph.stream() + .map(g -> cleanId(g.getAscendantIdName()) + + " -->" + (g.isAssumed() ? " " : "|XX| ") + + cleanId(g.getDescendantIdName())) + .sorted() + .collect(joining("\n")); + + final var avoidCroppedNodeLabels = "%%{init:{'flowchart':{'htmlLabels':false}}}%%\n\n"; + return (includes.contains(DETAILS) ? avoidCroppedNodeLabels : "") + + (graph.size() >= GRANT_LIMIT ? "%% too many grants, graph is cropped\n" : "") + + "flowchart TB\n\n" + + entities + + grants; + } + + private String renderSubgraph(final String entityId) { + // this does not work according to Mermaid bug https://github.com/mermaid-js/mermaid/issues/3806 + // if (entityId.contains("#")) { + // final var parts = entityId.split("#"); + // final var table = parts[0]; + // final var entity = parts[1]; + // if (table.equals("entity")) { + // return "[" + entity "]"; + // } + // return "[" + table + "\n" + entity + "]"; + // } + return "[" + cleanId(entityId) + "]"; + } + + private static String renderEntityIdName(final Node node) { + final var refType = refType(node.idName()); + if (refType.equals("user")) { + return "users"; + } + if (refType.equals("perm")) { + return node.idName().split(":", 3)[1]; + } + if (refType.equals("role")) { + final var withoutRolePrefix = node.idName().substring("role:".length()); + return withoutRolePrefix.substring(0, withoutRolePrefix.lastIndexOf(':')); + } + throw new IllegalArgumentException("unknown refType '" + refType + "' in '" + node.idName() + "'"); + } + + private String renderNode(final String idName, final UUID uuid) { + return cleanId(idName) + renderNodeContent(idName, uuid); + } + + private String renderNodeContent(final String idName, final UUID uuid) { + final var refType = refType(idName); + + if (refType.equals("user")) { + final var displayName = idName.substring(refType.length()+1); + return "(" + displayName + "\nref:" + uuid + ")"; + } + if (refType.equals("role")) { + final var roleType = idName.substring(idName.lastIndexOf(':') + 1); + return "[" + roleType + "\nref:" + uuid + "]"; + } + if (refType.equals("perm")) { + final var parts = idName.split(":"); + final var permType = parts[2]; + return "{{" + permType + "\nref:" + uuid + "}}"; + } + return ""; + } + + private static String refType(final String idName) { + return idName.split(":", 2)[0]; + } + + @NotNull + private static String cleanId(final String idName) { + return idName.replaceAll("@.*", "") + .replace("[", "").replace("]", "").replace("(", "").replace(")", "").replace(",", "").replace(">", ":").replace("|", "_"); + } + + + static class LimitedHashSet extends HashSet { + + @Override + public boolean add(final T t) { + if (size() < GRANT_LIMIT ) { + return super.add(t); + } else { + return false; + } + } + } + +} + +record Node(String idName, UUID uuid) { + +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacobject/BaseEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacobject/BaseEntity.java new file mode 100644 index 00000000..d0e7605f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacobject/BaseEntity.java @@ -0,0 +1,19 @@ +package net.hostsharing.hsadminng.rbac.rbacobject; + + +import org.hibernate.Hibernate; + +import java.util.UUID; + +// TODO.impl: this class does not really belong into this package, but there is no right place yet +public interface BaseEntity> { + UUID getUuid(); + + int getVersion(); + + default T load() { + Hibernate.initialize(this); + //noinspection unchecked + return (T) this; + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleEntity.java index 26528c8a..fa21785a 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleEntity.java @@ -34,6 +34,6 @@ public class RbacRoleEntity { @Enumerated(EnumType.STRING) private RbacRoleType roleType; - @Formula("objectTable||'#'||objectIdName||'.'||roleType") + @Formula("objectTable||'#'||objectIdName||':'||roleType") private String roleName; } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepository.java index 5d13f4db..94633d7c 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepository.java @@ -8,9 +8,12 @@ import java.util.UUID; public interface RbacRoleRepository extends Repository { /** - * Returns all instances of the type. - * - * @return all entities + * @return the number of persistent RbacRoleEntity instances, mostly for testing purposes. + */ + long count(); // TODO: move to test sources + + /** + * @return all persistent RbacRoleEntity instances, assigned to the current subject (user or assumed roles) */ List findAll(); diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleType.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleType.java index 153344fa..e78e8836 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleType.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleType.java @@ -1,5 +1,5 @@ package net.hostsharing.hsadminng.rbac.rbacrole; public enum RbacRoleType { - owner, admin, agent, tenant, guest + OWNER, ADMIN, AGENT, TENANT, GUEST, REFERRER } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserPermission.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserPermission.java index ba251885..f29503c3 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserPermission.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserPermission.java @@ -8,8 +8,8 @@ public interface RbacUserPermission { String getRoleName(); UUID getPermissionUuid(); String getOp(); + String getOpTableName(); String getObjectTable(); String getObjectIdName(); UUID getObjectUuid(); - } diff --git a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java similarity index 91% rename from src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerController.java rename to src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java index 1bd000ba..d0ab74bf 100644 --- a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java @@ -1,4 +1,4 @@ -package net.hostsharing.hsadminng.test.cust; +package net.hostsharing.hsadminng.rbac.test.cust; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.Mapper; @@ -10,6 +10,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import java.util.List; @RestController @@ -24,6 +26,9 @@ public class TestCustomerController implements TestCustomersApi { @Autowired private TestCustomerRepository testCustomerRepository; + @PersistenceContext + EntityManager em; + @Override @Transactional(readOnly = true) public ResponseEntity> listCustomers( @@ -48,7 +53,6 @@ public class TestCustomerController implements TestCustomersApi { context.define(currentUser, assumedRoles); final var saved = testCustomerRepository.save(mapper.map(customer, TestCustomerEntity.class)); - final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/test/customers/{id}") diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntity.java new file mode 100644 index 00000000..72df9c48 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntity.java @@ -0,0 +1,67 @@ +package net.hostsharing.hsadminng.rbac.test.cust; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; + +import jakarta.persistence.*; +import java.io.IOException; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "test_customer_rv") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class TestCustomerEntity implements BaseEntity { + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + private String prefix; + private int reference; + + @Column(name = "adminusername") + private String adminUserName; + + public static RbacView rbac() { + return rbacViewFor("customer", TestCustomerEntity.class) + .withIdentityView(SQL.projection("prefix")) + .withRestrictedViewOrderBy(SQL.expression("reference")) + .withUpdatableColumns("reference", "prefix", "adminUserName") + .toRole("global", ADMIN).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.owningUser(CREATOR).unassumed(); + with.incomingSuperRole(GLOBAL, ADMIN).unassumed(); + with.permission(DELETE); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(TENANT, (with) -> { + with.permission(SELECT); + }); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("2-test/201-test-customer/2013-test-customer-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepository.java similarity index 92% rename from src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepository.java rename to src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepository.java index 2dc298ea..773e2acd 100644 --- a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepository.java @@ -1,4 +1,4 @@ -package net.hostsharing.hsadminng.test.cust; +package net.hostsharing.hsadminng.rbac.test.cust; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/dom/TestDomainEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/dom/TestDomainEntity.java new file mode 100644 index 00000000..5d1369ca --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/dom/TestDomainEntity.java @@ -0,0 +1,73 @@ +package net.hostsharing.hsadminng.rbac.test.dom; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; +import net.hostsharing.hsadminng.rbac.test.pac.TestPackageEntity; + +import jakarta.persistence.*; +import java.io.IOException; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "test_domain_rv") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TestDomainEntity implements BaseEntity { + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(optional = false) + @JoinColumn(name = "packageuuid") + private TestPackageEntity pac; + + private String name; + + private String description; + + public static RbacView rbac() { + return rbacViewFor("domain", TestDomainEntity.class) + .withIdentityView(SQL.projection("name")) + .withUpdatableColumns("version", "packageUuid", "description") + + .importEntityAlias("package", TestPackageEntity.class, usingDefaultCase(), + dependsOnColumn("packageUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + .toRole("package", ADMIN).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("package", ADMIN); + with.outgoingSubRole("package", TENANT); + with.permission(DELETE); + with.permission(UPDATE); + }) + .createSubRole(ADMIN, (with) -> { + with.outgoingSubRole("package", TENANT); + with.permission(SELECT); + }); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("2-test/203-test-domain/2033-test-domain-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java similarity index 97% rename from src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageController.java rename to src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java index aaa7a9fe..8bb94971 100644 --- a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java @@ -1,4 +1,4 @@ -package net.hostsharing.hsadminng.test.pac; +package net.hostsharing.hsadminng.rbac.test.pac; import net.hostsharing.hsadminng.mapper.Mapper; import net.hostsharing.hsadminng.mapper.OptionalFromJson; diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntity.java new file mode 100644 index 00000000..8f4541d5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntity.java @@ -0,0 +1,74 @@ +package net.hostsharing.hsadminng.rbac.test.pac; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; +import net.hostsharing.hsadminng.rbac.test.cust.TestCustomerEntity; + +import jakarta.persistence.*; +import java.io.IOException; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; + +@Entity +@Table(name = "test_package_rv") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TestPackageEntity implements BaseEntity { + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(optional = false) + @JoinColumn(name = "customeruuid") + private TestCustomerEntity customer; + + private String name; + + private String description; + + + public static RbacView rbac() { + return rbacViewFor("package", TestPackageEntity.class) + .withIdentityView(SQL.projection("name")) + .withUpdatableColumns("version", "customerUuid", "description") + + .importEntityAlias("customer", TestCustomerEntity.class, usingDefaultCase(), + dependsOnColumn("customerUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + .toRole("customer", ADMIN).grantPermission(INSERT) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("customer", ADMIN); + with.permission(DELETE); + with.permission(UPDATE); + }) + .createSubRole(ADMIN) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("customer", TENANT); + with.permission(SELECT); + }); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("2-test/202-test-package/2023-test-package-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepository.java similarity index 91% rename from src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageRepository.java rename to src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepository.java index f8538465..5f4a13e5 100644 --- a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepository.java @@ -1,4 +1,4 @@ -package net.hostsharing.hsadminng.test.pac; +package net.hostsharing.hsadminng.rbac.test.pac; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; diff --git a/src/main/java/net/hostsharing/hsadminng/stringify/Stringify.java b/src/main/java/net/hostsharing/hsadminng/stringify/Stringify.java index 076f6209..269b0c69 100644 --- a/src/main/java/net/hostsharing/hsadminng/stringify/Stringify.java +++ b/src/main/java/net/hostsharing/hsadminng/stringify/Stringify.java @@ -1,21 +1,23 @@ package net.hostsharing.hsadminng.stringify; -import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.errors.DisplayAs; import jakarta.validation.constraints.NotNull; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import static java.lang.Boolean.TRUE; +import static java.util.Optional.ofNullable; public final class Stringify { - private final Class clazz; private final String name; + private Function idProp; private final List> props = new ArrayList<>(); private String separator = ", "; private Boolean quotedValues = null; @@ -28,12 +30,23 @@ public final class Stringify { return new Stringify<>(clazz, null); } + public Stringify using(final Class subClass) { + //noinspection unchecked + final var stringify = new Stringify(subClass, null) + .withIdProp(cast(idProp)) + .withProps(cast(props)) + .withSeparator(separator); + if (quotedValues != null) { + stringify.quotedValues(quotedValues); + } + return stringify; + } + private Stringify(final Class clazz, final String name) { - this.clazz = clazz; if (name != null) { this.name = name; } else { - final var displayName = clazz.getAnnotation(DisplayName.class); + final var displayName = clazz.getAnnotation(DisplayAs.class); if (displayName != null) { this.name = displayName.value(); } else { @@ -42,6 +55,11 @@ public final class Stringify { } } + public Stringify withIdProp(final Function getter) { + idProp = getter; + return this; + } + public Stringify withProp(final String propName, final Function getter) { props.add(new Property<>(propName, getter)); return this; @@ -52,10 +70,16 @@ public final class Stringify { return this; } + private Stringify withProps(final List> props) { + this.props.addAll(props); + return this; + } + public String apply(@NotNull B object) { final var propValues = props.stream() .map(prop -> PropertyValue.of(prop, prop.getter.apply(object))) .filter(Objects::nonNull) + .filter(PropertyValue::nonEmpty) .map(propVal -> { if (propVal.rawValue instanceof Stringifyable stringifyable) { return new PropertyValue<>(propVal.prop, propVal.rawValue, stringifyable.toShortString()); @@ -64,7 +88,9 @@ public final class Stringify { }) .map(propVal -> propName(propVal, "=") + optionallyQuoted(propVal)) .collect(Collectors.joining(separator)); - return name + "(" + propValues + ")"; + return idProp != null + ? name + "(" + idProp.apply(cast(object)) + ": " + propValues + ")" + : name + "(" + propValues + ")"; } public Stringify withSeparator(final String separator) { @@ -73,7 +99,7 @@ public final class Stringify { } private String propName(final PropertyValue propVal, final String delimiter) { - return Optional.ofNullable(propVal.prop.name).map(v -> v + delimiter).orElse(""); + return ofNullable(propVal.prop.name).map(v -> v + delimiter).orElse(""); } private String optionallyQuoted(final PropertyValue propVal) { @@ -95,6 +121,11 @@ public final class Stringify { return this; } + private T cast(final Object object) { + //noinspection unchecked + return (T)object; + } + private record Property(String name, Function getter) {} private record PropertyValue(Property prop, Object rawValue, String value) { @@ -102,5 +133,12 @@ public final class Stringify { static PropertyValue of(Property prop, Object rawValue) { return rawValue != null ? new PropertyValue<>(prop, rawValue, rawValue.toString()) : null; } + + boolean nonEmpty() { + return rawValue != null && + (!(rawValue instanceof Collection c) || !c.isEmpty()) && + (!(rawValue instanceof Map m) || !m.isEmpty()) && + (!(rawValue instanceof String s) || !s.isEmpty()); + } } } diff --git a/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java b/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java new file mode 100644 index 00000000..8b302098 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java @@ -0,0 +1,62 @@ +package net.hostsharing.hsadminng.system; + +import lombok.Getter; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; + +public class SystemProcess { + private final ProcessBuilder processBuilder; + + @Getter + private String stdOut; + @Getter + private String stdErr; + + public SystemProcess(final String... command) { + this.processBuilder = new ProcessBuilder(command); + } + + + public String getCommand() { + return processBuilder.command().toString(); + } + + public int execute() throws IOException, InterruptedException { + final var process = processBuilder.start(); + stdOut = fetchOutput(process.getInputStream()); // yeah, twisted ProcessBuilder API + stdErr = fetchOutput(process.getErrorStream()); + return process.waitFor(); + } + + public int execute(final String input) throws IOException, InterruptedException { + final var process = processBuilder.start(); + feedInput(input, process); + stdOut = fetchOutput(process.getInputStream()); // yeah, twisted ProcessBuilder API + stdErr = fetchOutput(process.getErrorStream()); + return process.waitFor(); + } + + private static void feedInput(final String input, final Process process) throws IOException { + try ( + final OutputStreamWriter stdIn = new OutputStreamWriter(process.getOutputStream()); // yeah, twisted ProcessBuilder API + final BufferedWriter writer = new BufferedWriter(stdIn)) { + writer.write(input); + writer.flush(); + } + } + + private static String fetchOutput(final InputStream inputStream) throws IOException { + final var output = new StringBuilder(); + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append(System.lineSeparator()); + } + } + return output.toString(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerEntity.java b/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerEntity.java deleted file mode 100644 index 1f2bb0e1..00000000 --- a/src/main/java/net/hostsharing/hsadminng/test/cust/TestCustomerEntity.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.hostsharing.hsadminng.test.cust; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import jakarta.persistence.*; -import java.util.UUID; - -@Entity -@Table(name = "test_customer_rv") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class TestCustomerEntity { - - @Id - @GeneratedValue - private UUID uuid; - - private String prefix; - private int reference; - - @Column(name = "adminusername") - private String adminUserName; -} diff --git a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageEntity.java b/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageEntity.java deleted file mode 100644 index 8687666f..00000000 --- a/src/main/java/net/hostsharing/hsadminng/test/pac/TestPackageEntity.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.hostsharing.hsadminng.test.pac; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import net.hostsharing.hsadminng.test.cust.TestCustomerEntity; - -import jakarta.persistence.*; -import java.util.UUID; - -@Entity -@Table(name = "test_package_rv") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class TestPackageEntity { - - @Id - @GeneratedValue - private UUID uuid; - - @Version - private int version; - - @ManyToOne(optional = false) - @JoinColumn(name = "customeruuid") - private TestCustomerEntity customer; - - private String name; - - private String description; -} diff --git a/src/main/resources/api-definition/hs-booking/api-mappings.yaml b/src/main/resources/api-definition/hs-booking/api-mappings.yaml new file mode 100644 index 00000000..18f34c1f --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/api-mappings.yaml @@ -0,0 +1,19 @@ +openapi-processor-mapping: v2 + +options: + package-name: net.hostsharing.hsadminng.hs.booking.generated.api.v1 + model-name-suffix: Resource + bean-validation: true + +map: + result: org.springframework.http.ResponseEntity + + types: + - type: array => java.util.List + - type: string:uuid => java.util.UUID + + paths: + /api/hs/booking/projects/{bookingProjectUuid}: + null: org.openapitools.jackson.nullable.JsonNullable + /api/hs/booking/items/{bookingItemUuid}: + null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-booking/auth.yaml b/src/main/resources/api-definition/hs-booking/auth.yaml new file mode 100644 index 00000000..65d491fb --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/auth.yaml @@ -0,0 +1,20 @@ + +components: + + parameters: + + currentUser: + name: current-user + in: header + required: true + schema: + type: string + description: Identifying name of the currently logged in user. + + assumedRoles: + name: assumed-roles + in: header + required: false + schema: + type: string + description: Semicolon-separated list of roles to assume. The current user needs to have the right to assume these roles. diff --git a/src/main/resources/api-definition/hs-booking/error-responses.yaml b/src/main/resources/api-definition/hs-booking/error-responses.yaml new file mode 100644 index 00000000..83ca3dfb --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/error-responses.yaml @@ -0,0 +1,40 @@ +components: + + responses: + NotFound: + description: The specified was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: The current user is unknown or not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: The current user or none of the assumed or roles is granted access to the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: The request could not be completed due to a conflict with the current state of the target resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml new file mode 100644 index 00000000..b18c7356 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml @@ -0,0 +1,116 @@ + +components: + + schemas: + + HsBookingItemType: + type: string + enum: + - PRIVATE_CLOUD + - CLOUD_SERVER + - MANAGED_SERVER + - MANAGED_WEBSPACE + + HsBookingItem: + type: object + properties: + uuid: + type: string + format: uuid + type: + $ref: '#/components/schemas/HsBookingItemType' + caption: + type: string + validFrom: + type: string + format: date + validTo: + type: string + format: date + resources: + $ref: '#/components/schemas/BookingResources' + required: + - uuid + - validFrom + - validTo + - resources + + HsBookingItemPatch: + type: object + properties: + caption: + type: string + nullable: true + validTo: + type: string + format: date + nullable: true + resources: + $ref: '#/components/schemas/BookingResources' + + HsBookingItemInsert: + type: object + properties: + projectUuid: + type: string + format: uuid + nullable: false + type: + $ref: '#/components/schemas/HsBookingItemType' + caption: + type: string + minLength: 3 + maxLength: 80 + nullable: false + validTo: + type: string + format: date + nullable: true + resources: + $ref: '#/components/schemas/BookingResources' + required: + - caption + - projectUuid + - validFrom + - resources + additionalProperties: false + + BookingResources: + anyOf: + - $ref: '#/components/schemas/ManagedServerBookingResources' + - $ref: '#/components/schemas/ManagedWebspaceBookingResources' + + ManagedServerBookingResources: + type: object + properties: + CPU: + type: integer + minimum: 1 + maximum: 16 + SSD: + type: integer + minimum: 16 + maximum: 4096 + HDD: + type: integer + minimum: 16 + maximum: 4096 + additionalProperties: false + + ManagedWebspaceBookingResources: + type: object + properties: + disk: + type: integer + minimum: 1 + maximum: 16 + SSD: + type: integer + minimum: 16 + maximum: 4096 + HDD: + type: integer + minimum: 16 + maximum: 4096 + additionalProperties: false + diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-items-with-uuid.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-items-with-uuid.yaml new file mode 100644 index 00000000..3d7567c8 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-items-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-booking-items + description: 'Fetch a single booking item its uuid, if visible for the current subject.' + operationId: getBookingItemByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingItemUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking item to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-booking-items + description: 'Updates a single booking item identified by its uuid, if permitted for the current subject.' + operationId: patchBookingItem + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingItemUuid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItemPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-booking-items + description: 'Delete a single booking item identified by its uuid, if permitted for the current subject.' + operationId: deleteBookingIemByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingItemUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking item to delete. + responses: + "204": + description: No Content + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml new file mode 100644 index 00000000..40a3d010 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml @@ -0,0 +1,58 @@ +get: + summary: Returns a list of all booking items for a specified project. + description: Returns the list of all booking items for a specified project which are visible to the current user or any of it's assumed roles. + tags: + - hs-booking-items + operationId: listBookingItemsByProjectUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: projectUuid + in: query + required: true + schema: + type: string + format: uuid + description: The UUID of the project, whose booking items are to be listed. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new booking item. + tags: + - hs-booking-items + operationId: addBookingItem + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new booking item. + required: true + content: + application/json: + schema: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItemInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml new file mode 100644 index 00000000..de95203d --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml @@ -0,0 +1,40 @@ + +components: + + schemas: + + HsBookingProject: + type: object + properties: + uuid: + type: string + format: uuid + caption: + type: string + required: + - uuid + - caption + + HsBookingProjectPatch: + type: object + properties: + caption: + type: string + nullable: true + + HsBookingProjectInsert: + type: object + properties: + debitorUuid: + type: string + format: uuid + nullable: false + caption: + type: string + minLength: 3 + maxLength: 80 + nullable: false + required: + - debitorUuid + - caption + additionalProperties: false diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml new file mode 100644 index 00000000..085205a7 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-booking-projects + description: 'Fetch a single booking project its uuid, if visible for the current subject.' + operationId: getBookingProjectByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking project to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-booking-projects + description: 'Updates a single booking project identified by its uuid, if permitted for the current subject.' + operationId: patchBookingProject + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProjectPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-booking-projects + description: 'Delete a single booking project identified by its uuid, if permitted for the current subject.' + operationId: deleteBookingIemByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking project to delete. + responses: + "204": + description: No Content + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml new file mode 100644 index 00000000..bccb7443 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml @@ -0,0 +1,58 @@ +get: + summary: Returns a list of all booking projects for a specified debitor. + description: Returns the list of all booking projects for a specified debitor which are visible to the current user or any of it's assumed roles. + tags: + - hs-booking-projects + operationId: listBookingProjectsByDebitorUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: debitorUuid + in: query + required: true + schema: + type: string + format: uuid + description: The UUID of the debitor, whose booking projects are to be listed. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new project as a container for booking items. + tags: + - hs-booking-projects + operationId: addBookingProject + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new booking project. + required: true + content: + application/json: + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProjectInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking.yaml b/src/main/resources/api-definition/hs-booking/hs-booking.yaml new file mode 100644 index 00000000..6faaf47c --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Hostsharing hsadmin-ng API + version: v0 +servers: + - url: http://localhost:8080 + description: Local development default URL. + +paths: + + # Projects + + /api/hs/booking/projects: + $ref: "hs-booking-projects.yaml" + + /api/hs/booking/projects/{bookingProjectUuid}: + $ref: "hs-booking-projects-with-uuid.yaml" + + + # Items + + /api/hs/booking/items: + $ref: "hs-booking-items.yaml" + + /api/hs/booking/items/{bookingItemUuid}: + $ref: "hs-booking-items-with-uuid.yaml" diff --git a/src/main/resources/api-definition/hs-hosting/api-mappings.yaml b/src/main/resources/api-definition/hs-hosting/api-mappings.yaml new file mode 100644 index 00000000..93f3cfe6 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/api-mappings.yaml @@ -0,0 +1,17 @@ +openapi-processor-mapping: v2 + +options: + package-name: net.hostsharing.hsadminng.hs.hosting.generated.api.v1 + model-name-suffix: Resource + bean-validation: true + +map: + result: org.springframework.http.ResponseEntity + + types: + - type: array => java.util.List + - type: string:uuid => java.util.UUID + + paths: + /api/hs/hosting/assets/{assetUuid}: + null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-hosting/auth.yaml b/src/main/resources/api-definition/hs-hosting/auth.yaml new file mode 100644 index 00000000..65d491fb --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/auth.yaml @@ -0,0 +1,20 @@ + +components: + + parameters: + + currentUser: + name: current-user + in: header + required: true + schema: + type: string + description: Identifying name of the currently logged in user. + + assumedRoles: + name: assumed-roles + in: header + required: false + schema: + type: string + description: Semicolon-separated list of roles to assume. The current user needs to have the right to assume these roles. diff --git a/src/main/resources/api-definition/hs-hosting/error-responses.yaml b/src/main/resources/api-definition/hs-hosting/error-responses.yaml new file mode 100644 index 00000000..83ca3dfb --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/error-responses.yaml @@ -0,0 +1,40 @@ +components: + + responses: + NotFound: + description: The specified was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: The current user is unknown or not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: The current user or none of the assumed or roles is granted access to the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: The request could not be completed due to a conflict with the current state of the target resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml new file mode 100644 index 00000000..b65a8a51 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml @@ -0,0 +1,185 @@ + +components: + + schemas: + + HsHostingAssetType: + type: string + enum: + - CLOUD_SERVER + - MANAGED_SERVER + - MANAGED_WEBSPACE + - UNIX_USER + - DOMAIN_SETUP + - DOMAIN_DNS_SETUP + - DOMAIN_HTTP_SETUP + - DOMAIN_SMTP_SETUP + - DOMAIN_MBOX_SETUP + - EMAIL_ALIAS + - EMAIL_ADDRESS + - PGSQL_INSTANCE + - PGSQL_USER + - PGSQL_DATABASE + - MARIADB_INSTANCE + - MARIADB_USER + - MARIADB_DATABASE + - IPV4_NUMBER + - IPV6_NUMBER + + HsHostingAsset: + type: object + properties: + uuid: + type: string + format: uuid + type: + $ref: '#/components/schemas/HsHostingAssetType' + identifier: + type: string + caption: + type: string + alarmContact: + $ref: '../hs-office/hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + config: + $ref: '#/components/schemas/HsHostingAssetConfiguration' + required: + - type + - ídentifier + - uuid + - config + + HsHostingAssetPatch: + type: object + properties: + caption: + type: string + nullable: true + alarmContactUuid: + type: string + format: uuid + nullable: true + config: + $ref: '#/components/schemas/HsHostingAssetConfiguration' + + HsHostingAssetInsert: + type: object + properties: + bookingItemUuid: + type: string + format: uuid + nullable: true + parentAssetUuid: + type: string + format: uuid + nullable: true + type: + $ref: '#/components/schemas/HsHostingAssetType' + identifier: + type: string + minLength: 3 + maxLength: 80 + nullable: false + caption: + type: string + minLength: 3 + maxLength: 80 + nullable: false + alarmContactUuid: + type: string + format: uuid + nullable: true + config: + $ref: '#/components/schemas/HsHostingAssetConfiguration' + required: + - type + - identifier + - caption + - config + additionalProperties: false + + HsHostingAssetConfiguration: + # forces generating a java.lang.Object containing a Map, instead of class AssetConfiguration + anyOf: + - type: object + # single source of supported properties just via /api/hs/hosting/asset-types/{assetType} + # TODO.impl: later, we could generate the config types and their properties from the validation config + additionalProperties: true + + PropertyDescriptor: + type: object + properties: + "type": + type: string + enum: + - integer + - boolean + - enumeration + "propertyName": + type: string + pattern: "^[ a-zA-Z0-9_-]$" + "required": + type: boolean + required: + - type + - propertyName + - required + + IntegerPropertyDescriptor: + allOf: + - $ref: '#/components/schemas/PropertyDescriptor' + - type: object + properties: + "type": + type: string + enum: + - integer + "unit": + type: string + "min": + type: integer + minimum: 0 + "max": + type: integer + minimum: 0 + "step": + type: integer + minimum: 1 + required: + - "type" + - "propertyName" + - "required" + + BooleanPropertyDescriptor: + allOf: + - $ref: '#/components/schemas/PropertyDescriptor' + - type: object + properties: + "type": + type: string + enum: + - boolean + "falseIf": + type: object + anyOf: + - type: object + additionalProperties: true + + EnumerationPropertyDescriptor: + allOf: + - $ref: '#/components/schemas/PropertyDescriptor' + - type: object + properties: + "type": + type: string + enum: + - enumeration + "values": + type: array + items: + type: string + + HsHostingAssetProps: + anyOf: + - $ref: '#/components/schemas/IntegerPropertyDescriptor' + - $ref: '#/components/schemas/BooleanPropertyDescriptor' + - $ref: '#/components/schemas/EnumerationPropertyDescriptor' diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml new file mode 100644 index 00000000..c7723c22 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types-props.yaml @@ -0,0 +1,26 @@ +get: + summary: Returns a list of available asset properties for the given type. + description: Returns the list of available properties and their validations for a given asset type. + tags: + - hs-hosting-asset-props + operationId: listAssetTypeProps + parameters: + - name: assetType + in: path + required: true + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetType' + description: The asset type whose properties are to be returned. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetProps' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml new file mode 100644 index 00000000..f1ab17e0 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-types.yaml @@ -0,0 +1,19 @@ +get: + summary: Returns a list of available asset types. + description: Returns the list of asset types to enable an adaptive UI. + tags: + - hs-hosting-asset-props + operationId: listAssetTypes + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + type: string + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets-with-uuid.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets-with-uuid.yaml new file mode 100644 index 00000000..6630d245 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-hosting-assets + description: 'Fetch a single managed asset by its uuid, if visible for the current subject.' + operationId: getAssetByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: assetUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the hosting asset to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAsset' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-hosting-assets + description: 'Updates a single hosting asset identified by its uuid, if permitted for the current subject.' + operationId: patchAsset + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: assetUuid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAsset' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-hosting-assets + description: 'Delete a single hosting asset identified by its uuid, if permitted for the current subject.' + operationId: deleteAssetUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: assetUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the hosting asset to delete. + responses: + "204": + description: No Content + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml new file mode 100644 index 00000000..8a208c68 --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml @@ -0,0 +1,71 @@ +get: + summary: Returns a filtered list of all hosting assets. + description: Returns the list of all hosting assets which match the given filters and are visible to the current user or any of it's assumed roles. + tags: + - hs-hosting-assets + operationId: listAssets + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: projectUuid + in: query + required: false + schema: + type: string + format: uuid + description: The UUID of the project, whose hosting assets are to be listed. + - name: parentAssetUuid + in: query + required: false + schema: + type: string + format: uuid + description: The UUID of the parentAsset, whose hosting assets are to be listed. + - name: type + in: query + required: false + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetType' + description: The type of hosting assets to be listed. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAsset' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new hosting asset. + tags: + - hs-hosting-assets + operationId: addAsset + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new hosting asset. + required: true + content: + application/json: + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAssetInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'hs-hosting-asset-schemas.yaml#/components/schemas/HsHostingAsset' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml new file mode 100644 index 00000000..b0df69dc --- /dev/null +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.3 +info: + title: Hostsharing hsadmin-ng API + version: v0 +servers: + - url: http://localhost:8080 + description: Local development default URL. + +paths: + + # Assets + + /api/hs/hosting/assets: + $ref: "hs-hosting-assets.yaml" + + /api/hs/hosting/assets/{assetUuid}: + $ref: "hs-hosting-assets-with-uuid.yaml" + + # Asset-Types + + /api/hs/hosting/asset-types: + $ref: "hs-hosting-asset-types.yaml" + + /api/hs/hosting/asset-types/{assetType}: + $ref: "hs-hosting-asset-types-props.yaml" diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml index 11778eb0..2403e1e4 100644 --- a/src/main/resources/api-definition/hs-office/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml @@ -23,7 +23,7 @@ map: null: org.openapitools.jackson.nullable.JsonNullable /api/hs/office/persons/{personUUID}: null: org.openapitools.jackson.nullable.JsonNullable - /api/hs/office/relationships/{relationshipUUID}: + /api/hs/office/relations/{relationUUID}: null: org.openapitools.jackson.nullable.JsonNullable /api/hs/office/bankaccounts/{bankAccountUUID}: null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-office/hs-office-bankaccounts-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-bankaccounts-with-uuid.yaml index bcf80063..44f89fa1 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-bankaccounts-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-bankaccounts-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single bank account by its uuid, if visible for the current subject.' operationId: getBankAccountByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: bankAccountUUID in: path required: true @@ -19,11 +19,11 @@ get: content: 'application/json': schema: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -31,8 +31,8 @@ delete: description: 'Delete a single bank account by its uuid, if permitted for the current subject.' operationId: deleteBankAccountByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: bankAccountUUID in: path required: true @@ -44,8 +44,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-bankaccounts.yaml b/src/main/resources/api-definition/hs-office/hs-office-bankaccounts.yaml index 913be50f..75380d5d 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-bankaccounts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-bankaccounts.yaml @@ -5,8 +5,8 @@ get: - hs-office-bank-accounts operationId: listBankAccounts parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: holder in: query required: false @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new bank account. @@ -33,13 +33,13 @@ post: - hs-office-bank-accounts operationId: addBankAccount parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccountInsert' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccountInsert' required: true responses: "201": @@ -47,10 +47,10 @@ post: content: 'application/json': schema: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-contact-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-contact-schemas.yaml index 9d8dc76a..8b409fa4 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contact-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contact-schemas.yaml @@ -9,41 +9,64 @@ components: uuid: type: string format: uuid - label: + caption: type: string postalAddress: type: string emailAddresses: - type: string + $ref: '#/components/schemas/HsOfficeContactEmailAddresses' phoneNumbers: - type: string + $ref: '#/components/schemas/HsOfficeContactPhoneNumbers' HsOfficeContactInsert: type: object properties: - label: + caption: type: string postalAddress: type: string emailAddresses: - type: string + $ref: '#/components/schemas/HsOfficeContactEmailAddresses' phoneNumbers: - type: string + $ref: '#/components/schemas/HsOfficeContactPhoneNumbers' required: - - label + - caption HsOfficeContactPatch: type: object properties: - label: + caption: type: string nullable: true postalAddress: type: string nullable: true emailAddresses: - type: string - nullable: true + $ref: '#/components/schemas/HsOfficeContactEmailAddresses' phoneNumbers: - type: string - nullable: true + $ref: '#/components/schemas/HsOfficeContactPhoneNumbers' + + HsOfficeContactEmailAddresses: + # forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses + anyOf: + - type: object + additionalProperties: true + + HsOfficeContactPhoneNumbers: + # forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses + anyOf: + - type: object + properties: + phone_office: + type: string + nullable: true + phone_private: + type: string + nullable: true + phone_mobile: + type: string + nullable: true + fax: + type: string + nullable: true + additionalProperties: false diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts-with-uuid.yaml index 60f74fa5..13e96f39 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single business contact by its uuid, if visible for the current subject.' operationId: getContactByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: contactUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single contact by its uuid, if permitted for the current subject.' operationId: patchContact parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: contactUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactPatch' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactPatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single business contact by its uuid, if permitted for the current subject.' operationId: deleteContactByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: contactUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml index 89bf366a..52d54a87 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-contacts.yaml @@ -5,14 +5,14 @@ get: - hs-office-contacts operationId: listContacts parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false schema: type: string - description: Prefix of label to filter the results. + description: Prefix of caption to filter the results. responses: "200": description: OK @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new contact. @@ -33,13 +33,13 @@ post: - hs-office-contacts operationId: addContact parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert' required: true responses: "201": @@ -47,10 +47,10 @@ post: content: 'application/json': schema: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml index adfcc9e8..0c937767 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml @@ -32,6 +32,32 @@ components: type: string comment: type: string + adjustedAssetTx: + $ref: '#/components/schemas/HsOfficeReferencedCoopAssetsTransaction' + adjustmentAssetTx: + $ref: '#/components/schemas/HsOfficeReferencedCoopAssetsTransaction' + + HsOfficeReferencedCoopAssetsTransaction: + description: + Similar to `HsOfficeCoopAssetsTransaction` but without the self-referencing properties + (`adjustedAssetTx` and `adjustmentAssetTx`), to avoid recursive JSON. + type: object + properties: + uuid: + type: string + format: uuid + transactionType: + $ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType' + assetValue: + type: number + format: currency + valueDate: + type: string + format: date + reference: + type: string + comment: + type: string HsOfficeCoopAssetsTransactionInsert: type: object @@ -54,6 +80,9 @@ components: maxLength: 48 comment: type: string + reverseEntryUuid: + type: string + format: uuid required: - membershipUuid - transactionType diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopassets-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopassets-with-uuid.yaml index 6dae49c0..7fd6d243 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopassets-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single asset transaction by its uuid, if visible for the current subject.' operationId: getCoopAssetTransactionByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: assetTransactionUUID in: path required: true @@ -19,9 +19,9 @@ get: content: 'application/json': schema: - $ref: './hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' + $ref: 'hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml index 75b19f7f..aa0ae953 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml @@ -5,8 +5,8 @@ get: - hs-office-coopAssets operationId: listCoopAssets parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: membershipUuid in: query required: false @@ -36,11 +36,11 @@ get: schema: type: array items: - $ref: './hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' + $ref: 'hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new cooperative asset transaction. @@ -48,25 +48,25 @@ post: - hs-office-coopAssets operationId: addCoopAssetsTransaction parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: description: A JSON object describing the new cooperative assets transaction. required: true content: application/json: schema: - $ref: '/hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransactionInsert' + $ref: 'hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransactionInsert' responses: "201": description: Created content: 'application/json': schema: - $ref: './hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' + $ref: 'hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml index e20786da..680321be 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml @@ -27,6 +27,31 @@ components: type: string comment: type: string + adjustedShareTx: + $ref: '#/components/schemas/HsOfficeReferencedCoopSharesTransaction' + adjustmentShareTx: + $ref: '#/components/schemas/HsOfficeReferencedCoopSharesTransaction' + + HsOfficeReferencedCoopSharesTransaction: + description: + Similar to `HsOfficeCoopSharesTransaction` but without the self-referencing properties + (`adjustedShareTx` and `adjustmentShareTx`), to avoid recursive JSON. + type: object + properties: + uuid: + type: string + format: uuid + transactionType: + $ref: '#/components/schemas/HsOfficeCoopSharesTransactionType' + shareCount: + type: integer + valueDate: + type: string + format: date + reference: + type: string + comment: + type: string HsOfficeCoopSharesTransactionInsert: type: object @@ -48,6 +73,9 @@ components: maxLength: 48 comment: type: string + adjustedShareTxUuid: + type: string + format: uuid required: - membershipUuid - transactionType diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopshares-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopshares-with-uuid.yaml index 8d40ace8..cd7ff827 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopshares-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopshares-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single share transaction by its uuid, if visible for the current subject.' operationId: getCoopShareTransactionByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: shareTransactionUUID in: path required: true @@ -19,9 +19,9 @@ get: content: 'application/json': schema: - $ref: './hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' + $ref: 'hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopshares.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopshares.yaml index f24853d7..338018ad 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopshares.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopshares.yaml @@ -5,8 +5,8 @@ get: - hs-office-coopShares operationId: listCoopShares parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: membershipUuid in: query required: false @@ -36,11 +36,11 @@ get: schema: type: array items: - $ref: './hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' + $ref: 'hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new cooperative share transaction. @@ -48,25 +48,25 @@ post: - hs-office-coopShares operationId: addCoopSharesTransaction parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: description: A JSON object describing the new cooperative shares transaction. required: true content: application/json: schema: - $ref: '/hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransactionInsert' + $ref: 'hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransactionInsert' responses: "201": description: Created content: 'application/json': schema: - $ref: './hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' + $ref: 'hs-office-coopshares-schemas.yaml#/components/schemas/HsOfficeCoopSharesTransaction' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml index 26736fac..f38644c1 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml @@ -9,6 +9,8 @@ components: uuid: type: string format: uuid + debitorRel: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' debitorNumber: type: integer format: int32 @@ -20,9 +22,7 @@ components: minimum: 00 maximum: 99 partner: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' - billingContact: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' billable: type: boolean vatId: @@ -35,7 +35,7 @@ components: vatReverseCharge: type: boolean refundBankAccount: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' defaultPrefix: type: string pattern: '^[a-z0-9]{3}$' @@ -43,7 +43,7 @@ components: HsOfficeDebitorPatch: type: object properties: - billingContactUuid: + debitorRelUuid: type: string format: uuid nullable: true @@ -75,14 +75,11 @@ components: HsOfficeDebitorInsert: type: object properties: - partnerUuid: + debitorRel: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationInsert' + debitorRelUuid: type: string format: uuid - nullable: false - billingContactUuid: - type: string - format: uuid - nullable: false debitorNumberSuffix: type: integer format: int8 @@ -105,9 +102,7 @@ components: defaultPrefix: type: string pattern: '^[a-z]{3}$' - required: - - partnerUuid - - billingContactUuid + - debitorNumberSuffix - defaultPrefix - billable diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml index 3789879d..09c6d42d 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single debitor by its uuid, if visible for the current subject.' operationId: getDebitorByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: debitorUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single debitor by its uuid, if permitted for the current subject.' operationId: patchDebitor parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: debitorUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorPatch' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorPatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single debitor by its uuid, if permitted for the current subject.' operationId: deleteDebitorByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: debitorUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml index c35deb7a..5936198b 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml @@ -5,8 +5,8 @@ get: - hs-office-debitors operationId: listDebitors parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false @@ -27,11 +27,11 @@ get: schema: type: array items: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new debitor. @@ -39,13 +39,13 @@ post: - hs-office-debitors operationId: addDebitor parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorInsert' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorInsert' required: true responses: "201": @@ -53,10 +53,10 @@ post: content: 'application/json': schema: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml index 163f6f34..7132cff4 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml @@ -3,15 +3,17 @@ components: schemas: - HsOfficeReasonForTermination: + HsOfficeMembershipStatus: type: string enum: - - NONE - - CANCELLATION - - TRANSFER - - DEATH - - LIQUIDATION - - EXPULSION + - INVALID + - ACTIVE + - CANCELLED + - TRANSFERRED + - DECEASED + - LIQUIDATED + - EXPULSED + - UNKNOWN HsOfficeMembership: type: object @@ -20,9 +22,9 @@ components: type: string format: uuid partner: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' mainDebitor: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' memberNumber: type: integer minimum: 1000000 @@ -38,25 +40,20 @@ components: validTo: type: string format: date - reasonForTermination: - $ref: '#/components/schemas/HsOfficeReasonForTermination' + status: + $ref: '#/components/schemas/HsOfficeMembershipStatus' membershipFeeBillable: type: boolean HsOfficeMembershipPatch: type: object properties: - mainDebitorUuid: - type: string - format: uuid - nullable: true validTo: type: string format: date nullable: true - reasonForTermination: - nullable: true - $ref: '#/components/schemas/HsOfficeReasonForTermination' + status: + $ref: '#/components/schemas/HsOfficeMembershipStatus' membershipFeeBillable: nullable: true type: boolean @@ -69,10 +66,6 @@ components: type: string format: uuid nullable: false - mainDebitorUuid: - type: string - format: uuid - nullable: false memberNumberSuffix: type: string minLength: 2 @@ -87,15 +80,14 @@ components: type: string format: date nullable: true - reasonForTermination: - $ref: '#/components/schemas/HsOfficeReasonForTermination' + status: + $ref: '#/components/schemas/HsOfficeMembershipStatus' membershipFeeBillable: nullable: false type: boolean required: - partnerUuid - memberNumberSuffix - - mainDebitorUuid - validFrom - membershipFeeBillable additionalProperties: false diff --git a/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml index bec6911f..4bd1b3fb 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single membership by its uuid, if visible for the current subject.' operationId: getMembershipByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: membershipUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single membership by its uuid, if permitted for the current subject.' operationId: patchMembership parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: membershipUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipPatch' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipPatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single membership by its uuid, if permitted for the current subject.' operationId: deleteMembershipByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: membershipUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml b/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml index 3833752b..260dee51 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml @@ -6,8 +6,8 @@ get: - hs-office-memberships operationId: listMemberships parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: partnerUuid in: query required: false @@ -29,11 +29,11 @@ get: schema: type: array items: - $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new membership. @@ -41,25 +41,25 @@ post: - hs-office-memberships operationId: addMembership parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: description: A JSON object describing the new membership. required: true content: application/json: schema: - $ref: '/hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipInsert' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipInsert' responses: "201": description: Created content: 'application/json': schema: - $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' + $ref: 'hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml index a6a94f67..0e5952e1 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml @@ -14,10 +14,8 @@ components: format: int8 minimum: 10000 maximum: 99999 - person: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' - contact: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + partnerRel: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' details: $ref: '#/components/schemas/HsOfficePartnerDetails' @@ -52,11 +50,7 @@ components: HsOfficePartnerPatch: type: object properties: - personUuid: - type: string - format: uuid - nullable: true - contactUuid: + partnerRelUuid: type: string format: uuid nullable: true @@ -96,19 +90,32 @@ components: format: int8 minimum: 10000 maximum: 99999 - personUuid: + partnerRel: + $ref: '#/components/schemas/HsOfficePartnerRelInsert' + details: + $ref: '#/components/schemas/HsOfficePartnerDetailsInsert' + required: + - partnerNumber + - partnerRel + - details + + HsOfficePartnerRelInsert: + type: object + nullable: false + properties: + anchorUuid: + type: string + format: uuid + holderUuid: type: string format: uuid contactUuid: type: string format: uuid - details: - $ref: '#/components/schemas/HsOfficePartnerDetailsInsert' required: - - partnerNumber - - personUuid - - contactUuid - - details + - anchorUuid + - holderUuid + - relContactUuid HsOfficePartnerDetailsInsert: type: object diff --git a/src/main/resources/api-definition/hs-office/hs-office-partners-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-partners-with-uuid.yaml index bc9927f3..914df66b 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-partners-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-partners-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single business partner by its uuid, if visible for the current subject.' operationId: getPartnerByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: partnerUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single business partner by its uuid, if permitted for the current subject.' operationId: patchPartner parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: partnerUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartnerPatch' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartnerPatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single business partner by its uuid, if permitted for the current subject.' operationId: deletePartnerByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: partnerUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-partners.yaml b/src/main/resources/api-definition/hs-office/hs-office-partners.yaml index 80f356ab..1f6ee36e 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-partners.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-partners.yaml @@ -5,8 +5,8 @@ get: - hs-office-partners operationId: listPartners parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new business partner. @@ -33,13 +33,13 @@ post: - hs-office-partners operationId: addPartner parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartnerInsert' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartnerInsert' required: true responses: "201": @@ -47,10 +47,10 @@ post: content: 'application/json': schema: - $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + $ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml index 3e651feb..63fc3258 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml @@ -23,6 +23,10 @@ components: $ref: '#/components/schemas/HsOfficePersonType' tradeName: type: string + salutation: + type: string + title: + type: string givenName: type: string familyName: @@ -35,6 +39,10 @@ components: $ref: '#/components/schemas/HsOfficePersonType' tradeName: type: string + salutation: + type: string + title: + type: string givenName: type: string familyName: @@ -51,6 +59,12 @@ components: tradeName: type: string nullable: true + salutation: + type: string + nullable: true + title: + type: string + nullable: true givenName: type: string nullable: true diff --git a/src/main/resources/api-definition/hs-office/hs-office-persons-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-persons-with-uuid.yaml index 4d550fc9..1b90c777 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-persons-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-persons-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single business person by its uuid, if visible for the current subject.' operationId: getPersonByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: personUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single person by its uuid, if permitted for the current subject.' operationId: patchPerson parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: personUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonPatch' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonPatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single business person by its uuid, if permitted for the current subject.' operationId: deletePersonByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: personUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-persons.yaml b/src/main/resources/api-definition/hs-office/hs-office-persons.yaml index 3e6f0873..f7cba51a 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-persons.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-persons.yaml @@ -5,14 +5,14 @@ get: - hs-office-persons operationId: listPersons parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false schema: type: string - description: Prefix of label to filter the results. + description: Prefix of caption to filter the results. responses: "200": description: OK @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new person. @@ -33,13 +33,13 @@ post: - hs-office-persons operationId: addPerson parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' required: true responses: "201": @@ -47,10 +47,10 @@ post: content: 'application/json': schema: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml similarity index 56% rename from src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml rename to src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml index 8fb5abb2..e0448a6f 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml @@ -3,36 +3,37 @@ components: schemas: - HsOfficeRelationshipType: + HsOfficeRelationType: type: string enum: - UNKNOWN + - PARTNER - EX_PARTNER - - REPRESENTATIVE, + - DEBITOR + - REPRESENTATIVE - VIP_CONTACT - - ACCOUNTING, - OPERATIONS - SUBSCRIBER - HsOfficeRelationship: + HsOfficeRelation: type: object properties: uuid: type: string format: uuid - relAnchor: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' - relHolder: - $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' - relType: + anchor: + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + holder: + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + type: type: string - relMark: + mark: type: string nullable: true contact: - $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + $ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' - HsOfficeRelationshipPatch: + HsOfficeRelationPatch: type: object properties: contactUuid: @@ -40,24 +41,26 @@ components: format: uuid nullable: true - HsOfficeRelationshipInsert: + HsOfficeRelationInsert: type: object properties: - relAnchorUuid: + anchorUuid: type: string format: uuid - relHolderUuid: + holderUuid: type: string format: uuid - relType: + type: type: string nullable: true - relMark: + mark: type: string + nullable: true contactUuid: type: string format: uuid required: - - relAnchorUuid - - relHolderUuid - - relType + - anchorUuid + - holderUuid + - type + - contactUuid diff --git a/src/main/resources/api-definition/hs-office/hs-office-relations-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-relations-with-uuid.yaml new file mode 100644 index 00000000..4e8010e7 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-relations-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-office-relations + description: 'Fetch a single person relation by its uuid, if visible for the current subject.' + operationId: getRelationByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: relationUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the relation to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-office-relations + description: 'Updates a single person relation by its uuid, if permitted for the current subject.' + operationId: patchRelation + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: relationUUID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-office-relations + description: 'Delete a single person relation by its uuid, if permitted for the current subject.' + operationId: deleteRelationByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: relationUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the relation to delete. + responses: + "204": + description: No Content + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-relations.yaml b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml new file mode 100644 index 00000000..94131df5 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-relations.yaml @@ -0,0 +1,63 @@ +get: + summary: Returns a list of (optionally filtered) person relations for a given person. + description: Returns the list of (optionally filtered) person relations of a given person and which are visible to the current user or any of it's assumed roles. + tags: + - hs-office-relations + operationId: listRelations + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: personUuid + in: query + required: true + schema: + type: string + format: uuid + description: Prefix of name properties from holder or contact to filter the results. + - name: relationType + in: query + required: false + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationType' + description: Prefix of name properties from holder or contact to filter the results. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new person relation. + tags: + - hs-office-relations + operationId: addRelation + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationInsert' + required: true + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelation' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml deleted file mode 100644 index d3b9605e..00000000 --- a/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml +++ /dev/null @@ -1,83 +0,0 @@ -get: - tags: - - hs-office-relationships - description: 'Fetch a single person relationship by its uuid, if visible for the current subject.' - operationId: getRelationshipByUuid - parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' - - name: relationshipUUID - in: path - required: true - schema: - type: string - format: uuid - description: UUID of the relationship to fetch. - responses: - "200": - description: OK - content: - 'application/json': - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' - - "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' - -patch: - tags: - - hs-office-relationships - description: 'Updates a single person relationship by its uuid, if permitted for the current subject.' - operationId: patchRelationship - parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' - - name: relationshipUUID - in: path - required: true - schema: - type: string - format: uuid - requestBody: - content: - 'application/json': - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipPatch' - responses: - "200": - description: OK - content: - 'application/json': - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' - "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' - -delete: - tags: - - hs-office-relationships - description: 'Delete a single person relationship by its uuid, if permitted for the current subject.' - operationId: deleteRelationshipByUuid - parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' - - name: relationshipUUID - in: path - required: true - schema: - type: string - format: uuid - description: UUID of the relationship to delete. - responses: - "204": - description: No Content - "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' - "404": - $ref: './error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml deleted file mode 100644 index 2d7ed2fd..00000000 --- a/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml +++ /dev/null @@ -1,63 +0,0 @@ -get: - summary: Returns a list of (optionally filtered) person relationships for a given person. - description: Returns the list of (optionally filtered) person relationships of a given person and which are visible to the current user or any of it's assumed roles. - tags: - - hs-office-relationships - operationId: listRelationships - parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' - - name: personUuid - in: query - required: true - schema: - type: string - format: uuid - description: Prefix of name properties from relHolder or contact to filter the results. - - name: relationshipType - in: query - required: false - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipType' - description: Prefix of name properties from relHolder or contact to filter the results. - responses: - "200": - description: OK - content: - 'application/json': - schema: - type: array - items: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' - "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' - -post: - summary: Adds a new person relationship. - tags: - - hs-office-relationships - operationId: addRelationship - parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' - requestBody: - content: - 'application/json': - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipInsert' - required: true - responses: - "201": - description: Created - content: - 'application/json': - schema: - $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship' - "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' - "409": - $ref: './error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml index dd2af7fd..80668ba8 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml @@ -10,9 +10,9 @@ components: type: string format: uuid debitor: - $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + $ref: 'hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' bankAccount: - $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' + $ref: 'hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount' reference: type: string agreement: diff --git a/src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml index 4e21a9a2..52d050ee 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single SEPA Mandate by its uuid, if visible for the current subject.' operationId: getSepaMandateByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: sepaMandateUUID in: path required: true @@ -19,12 +19,12 @@ get: content: 'application/json': schema: - $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' patch: tags: @@ -32,8 +32,8 @@ patch: description: 'Updates a single SEPA Mandate by its uuid, if permitted for the current subject.' operationId: patchSepaMandate parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: sepaMandateUUID in: path required: true @@ -44,18 +44,18 @@ patch: content: 'application/json': schema: - $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandatePatch' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandatePatch' responses: "200": description: OK content: 'application/json': schema: - $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: tags: @@ -63,8 +63,8 @@ delete: description: 'Delete a single SEPA Mandate by its uuid, if permitted for the current subject.' operationId: deleteSepaMandateByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: sepaMandateUUID in: path required: true @@ -76,8 +76,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml b/src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml index 08244629..82f8f154 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml @@ -5,8 +5,8 @@ get: - hs-office-sepaMandates operationId: listSepaMandatesByIBAN parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new SEPA Mandate. @@ -33,25 +33,25 @@ post: - hs-office-sepaMandates operationId: addSepaMandate parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: description: A JSON object describing the new SEPA-Mandate. required: true content: application/json: schema: - $ref: '/hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandateInsert' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandateInsert' responses: "201": description: Created content: 'application/json': schema: - $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' + $ref: 'hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml index f3110867..e8e7816d 100644 --- a/src/main/resources/api-definition/hs-office/hs-office.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.1 +openapi: 3.0.3 info: title: Hostsharing hsadmin-ng API version: v0 @@ -11,87 +11,87 @@ paths: # Partners /api/hs/office/partners: - $ref: "./hs-office-partners.yaml" + $ref: "hs-office-partners.yaml" /api/hs/office/partners/{partnerUUID}: - $ref: "./hs-office-partners-with-uuid.yaml" + $ref: "hs-office-partners-with-uuid.yaml" # Contacts /api/hs/office/contacts: - $ref: "./hs-office-contacts.yaml" + $ref: "hs-office-contacts.yaml" /api/hs/office/contacts/{contactUUID}: - $ref: "./hs-office-contacts-with-uuid.yaml" + $ref: "hs-office-contacts-with-uuid.yaml" # Persons /api/hs/office/persons: - $ref: "./hs-office-persons.yaml" + $ref: "hs-office-persons.yaml" /api/hs/office/persons/{personUUID}: - $ref: "./hs-office-persons-with-uuid.yaml" + $ref: "hs-office-persons-with-uuid.yaml" - # Relationships + # Relations - /api/hs/office/relationships: - $ref: "./hs-office-relationships.yaml" + /api/hs/office/relations: + $ref: "hs-office-relations.yaml" - /api/hs/office/relationships/{relationshipUUID}: - $ref: "./hs-office-relationships-with-uuid.yaml" + /api/hs/office/relations/{relationUUID}: + $ref: "hs-office-relations-with-uuid.yaml" # BankAccounts /api/hs/office/bankaccounts: - $ref: "./hs-office-bankaccounts.yaml" + $ref: "hs-office-bankaccounts.yaml" /api/hs/office/bankaccounts/{bankAccountUUID}: - $ref: "./hs-office-bankaccounts-with-uuid.yaml" + $ref: "hs-office-bankaccounts-with-uuid.yaml" # Debitors /api/hs/office/debitors: - $ref: "./hs-office-debitors.yaml" + $ref: "hs-office-debitors.yaml" /api/hs/office/debitors/{debitorUUID}: - $ref: "./hs-office-debitors-with-uuid.yaml" + $ref: "hs-office-debitors-with-uuid.yaml" # SepaMandates /api/hs/office/sepamandates: - $ref: "./hs-office-sepamandates.yaml" + $ref: "hs-office-sepamandates.yaml" /api/hs/office/sepamandates/{sepaMandateUUID}: - $ref: "./hs-office-sepamandates-with-uuid.yaml" + $ref: "hs-office-sepamandates-with-uuid.yaml" # Membership /api/hs/office/memberships: - $ref: "./hs-office-memberships.yaml" + $ref: "hs-office-memberships.yaml" /api/hs/office/memberships/{membershipUUID}: - $ref: "./hs-office-memberships-with-uuid.yaml" + $ref: "hs-office-memberships-with-uuid.yaml" # Coop Shares Transaction /api/hs/office/coopsharestransactions: - $ref: "./hs-office-coopshares.yaml" + $ref: "hs-office-coopshares.yaml" /api/hs/office/coopsharestransactions/{shareTransactionUUID}: - $ref: "./hs-office-coopshares-with-uuid.yaml" + $ref: "hs-office-coopshares-with-uuid.yaml" # Coop Assets Transaction /api/hs/office/coopassetstransactions: - $ref: "./hs-office-coopassets.yaml" + $ref: "hs-office-coopassets.yaml" /api/hs/office/coopassetstransactions/{assetTransactionUUID}: - $ref: "./hs-office-coopassets-with-uuid.yaml" + $ref: "hs-office-coopassets-with-uuid.yaml" diff --git a/src/main/resources/api-definition/rbac/rbac-grants-with-id.yaml b/src/main/resources/api-definition/rbac/rbac-grants-with-id.yaml index 11f3aceb..b45ebb4e 100644 --- a/src/main/resources/api-definition/rbac/rbac-grants-with-id.yaml +++ b/src/main/resources/api-definition/rbac/rbac-grants-with-id.yaml @@ -3,8 +3,8 @@ get: - rbac-grants operationId: getGrantById parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: grantedRoleUuid in: path required: true @@ -25,21 +25,21 @@ get: content: 'application/json': schema: - $ref: './rbac-grant-schemas.yaml#/components/schemas/RbacGrant' + $ref: 'rbac-grant-schemas.yaml#/components/schemas/RbacGrant' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' delete: tags: - rbac-grants operationId: revokeRoleFromUser parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: grantedRoleUuid in: path required: true @@ -58,8 +58,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/rbac/rbac-grants.yaml b/src/main/resources/api-definition/rbac/rbac-grants.yaml index fd359a35..16011bcd 100644 --- a/src/main/resources/api-definition/rbac/rbac-grants.yaml +++ b/src/main/resources/api-definition/rbac/rbac-grants.yaml @@ -3,8 +3,8 @@ get: - rbac-grants operationId: listUserGrants parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' responses: "200": description: OK @@ -13,31 +13,31 @@ get: schema: type: array items: - $ref: './rbac-grant-schemas.yaml#/components/schemas/RbacGrant' + $ref: 'rbac-grant-schemas.yaml#/components/schemas/RbacGrant' post: tags: - rbac-grants operationId: grantRoleToUser parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: required: true content: application/json: schema: - $ref: './rbac-grant-schemas.yaml#/components/schemas/RbacGrant' + $ref: 'rbac-grant-schemas.yaml#/components/schemas/RbacGrant' responses: "201": description: OK content: 'application/json': schema: - $ref: './rbac-grant-schemas.yaml#/components/schemas/RbacGrant' + $ref: 'rbac-grant-schemas.yaml#/components/schemas/RbacGrant' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/rbac/rbac-role-schemas.yaml b/src/main/resources/api-definition/rbac/rbac-role-schemas.yaml index 589c00b8..4e5b5f4d 100644 --- a/src/main/resources/api-definition/rbac/rbac-role-schemas.yaml +++ b/src/main/resources/api-definition/rbac/rbac-role-schemas.yaml @@ -19,8 +19,11 @@ components: roleType: type: string enum: - - owner - - admin - - tenant + - OWNER + - ADMIN + - AGENT + - TENANT + - REFERRER + - GUEST roleName: type: string diff --git a/src/main/resources/api-definition/rbac/rbac-roles.yaml b/src/main/resources/api-definition/rbac/rbac-roles.yaml index 8d139d6b..b97aa387 100644 --- a/src/main/resources/api-definition/rbac/rbac-roles.yaml +++ b/src/main/resources/api-definition/rbac/rbac-roles.yaml @@ -3,8 +3,8 @@ get: - rbac-roles operationId: listRoles parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' responses: "200": description: OK @@ -13,4 +13,4 @@ get: schema: type: array items: - $ref: './rbac-role-schemas.yaml#/components/schemas/RbacRole' + $ref: 'rbac-role-schemas.yaml#/components/schemas/RbacRole' diff --git a/src/main/resources/api-definition/rbac/rbac-users-with-id-permissions.yaml b/src/main/resources/api-definition/rbac/rbac-users-with-id-permissions.yaml index c8353a88..ba6eb3fe 100644 --- a/src/main/resources/api-definition/rbac/rbac-users-with-id-permissions.yaml +++ b/src/main/resources/api-definition/rbac/rbac-users-with-id-permissions.yaml @@ -4,8 +4,8 @@ get: description: 'List all visible permissions granted to the given user; reduced ' operationId: listUserPermissions parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: userUuid in: path required: true @@ -20,9 +20,9 @@ get: schema: type: array items: - $ref: './rbac-user-schemas.yaml#/components/schemas/RbacUserPermission' + $ref: 'rbac-user-schemas.yaml#/components/schemas/RbacUserPermission' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/rbac/rbac-users-with-uuid.yaml b/src/main/resources/api-definition/rbac/rbac-users-with-uuid.yaml index 52124ab9..058fc5cd 100644 --- a/src/main/resources/api-definition/rbac/rbac-users-with-uuid.yaml +++ b/src/main/resources/api-definition/rbac/rbac-users-with-uuid.yaml @@ -4,8 +4,8 @@ get: description: 'Fetch a single user by its id, if visible for the current subject.' operationId: getUserById parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: userUuid in: path required: true @@ -18,12 +18,12 @@ get: content: 'application/json': schema: - $ref: './rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: 'rbac-user-schemas.yaml#/components/schemas/RbacUser' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' delete: @@ -31,8 +31,8 @@ delete: - rbac-users operationId: deleteUserByUuid parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: userUuid in: path required: true @@ -44,8 +44,8 @@ delete: "204": description: No Content "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "404": - $ref: './error-responses.yaml#/components/responses/NotFound' + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/rbac/rbac-users.yaml b/src/main/resources/api-definition/rbac/rbac-users.yaml index 02f7d234..4acb729e 100644 --- a/src/main/resources/api-definition/rbac/rbac-users.yaml +++ b/src/main/resources/api-definition/rbac/rbac-users.yaml @@ -4,8 +4,8 @@ get: description: List accessible RBAC users with optional filter by name. operationId: listUsers parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false @@ -19,11 +19,11 @@ get: schema: type: array items: - $ref: './rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: 'rbac-user-schemas.yaml#/components/schemas/RbacUser' '401': - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' '403': - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: tags: @@ -35,14 +35,14 @@ post: content: application/json: schema: - $ref: './rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: 'rbac-user-schemas.yaml#/components/schemas/RbacUser' responses: '201': description: Created content: 'application/json': schema: - $ref: './rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: 'rbac-user-schemas.yaml#/components/schemas/RbacUser' '409': - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/rbac/rbac.yaml b/src/main/resources/api-definition/rbac/rbac.yaml index dc48fc05..ad6dfca4 100644 --- a/src/main/resources/api-definition/rbac/rbac.yaml +++ b/src/main/resources/api-definition/rbac/rbac.yaml @@ -9,20 +9,20 @@ servers: paths: /api/rbac/users: - $ref: './rbac-users.yaml' + $ref: 'rbac-users.yaml' /api/rbac/users/{userUuid}/permissions: - $ref: './rbac-users-with-id-permissions.yaml' + $ref: 'rbac-users-with-id-permissions.yaml' /api/rbac/users/{userUuid}: - $ref: './rbac-users-with-uuid.yaml' + $ref: 'rbac-users-with-uuid.yaml' /api/rbac/roles: - $ref: './rbac-roles.yaml' + $ref: 'rbac-roles.yaml' /api/rbac/grants: - $ref: './rbac-grants.yaml' + $ref: 'rbac-grants.yaml' /api/rbac/grants/{grantedRoleUuid}/{granteeUserUuid}: - $ref: './rbac-grants-with-id.yaml' + $ref: 'rbac-grants-with-id.yaml' diff --git a/src/main/resources/api-definition/test/test-customers.yaml b/src/main/resources/api-definition/test/test-customers.yaml index 449ed732..89a8fb6b 100644 --- a/src/main/resources/api-definition/test/test-customers.yaml +++ b/src/main/resources/api-definition/test/test-customers.yaml @@ -5,8 +5,8 @@ get: - testCustomers operationId: listCustomers parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: prefix in: query required: false @@ -21,11 +21,11 @@ get: schema: type: array items: - $ref: './test-customer-schemas.yaml#/components/schemas/TestCustomer' + $ref: 'test-customer-schemas.yaml#/components/schemas/TestCustomer' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' post: summary: Adds a new customer. @@ -33,13 +33,13 @@ post: - testCustomers operationId: addCustomer parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: content: 'application/json': schema: - $ref: './test-customer-schemas.yaml#/components/schemas/TestCustomer' + $ref: 'test-customer-schemas.yaml#/components/schemas/TestCustomer' required: true responses: "201": @@ -47,10 +47,10 @@ post: content: 'application/json': schema: - $ref: './test-customer-schemas.yaml#/components/schemas/TestCustomer' + $ref: 'test-customer-schemas.yaml#/components/schemas/TestCustomer' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' "409": - $ref: './error-responses.yaml#/components/responses/Conflict' + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/test/test-package-schemas.yaml b/src/main/resources/api-definition/test/test-package-schemas.yaml index d9e6eb34..dfdeb031 100644 --- a/src/main/resources/api-definition/test/test-package-schemas.yaml +++ b/src/main/resources/api-definition/test/test-package-schemas.yaml @@ -10,7 +10,7 @@ components: type: string format: uuid customer: - $ref: './test-customer-schemas.yaml#/components/schemas/TestCustomer' + $ref: 'test-customer-schemas.yaml#/components/schemas/TestCustomer' name: type: string description: diff --git a/src/main/resources/api-definition/test/test-packages-uuid.yaml b/src/main/resources/api-definition/test/test-packages-uuid.yaml index 6b3b1398..4fc8ef80 100644 --- a/src/main/resources/api-definition/test/test-packages-uuid.yaml +++ b/src/main/resources/api-definition/test/test-packages-uuid.yaml @@ -3,8 +3,8 @@ patch: - testPackages operationId: updatePackage parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: packageUUID in: path required: true @@ -15,15 +15,15 @@ patch: content: 'application/json': schema: - $ref: './test-package-schemas.yaml#/components/schemas/TestPackageUpdate' + $ref: 'test-package-schemas.yaml#/components/schemas/TestPackageUpdate' responses: "200": description: OK content: 'application/json': schema: - $ref: './test-package-schemas.yaml#/components/schemas/TestPackage' + $ref: 'test-package-schemas.yaml#/components/schemas/TestPackage' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/test/test-packages.yaml b/src/main/resources/api-definition/test/test-packages.yaml index 53bc128b..6a3e0e7f 100644 --- a/src/main/resources/api-definition/test/test-packages.yaml +++ b/src/main/resources/api-definition/test/test-packages.yaml @@ -3,8 +3,8 @@ get: - testPackages operationId: listPackages parameters: - - $ref: './auth.yaml#/components/parameters/currentUser' - - $ref: './auth.yaml#/components/parameters/assumedRoles' + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: name in: query required: false @@ -18,8 +18,8 @@ get: schema: type: array items: - $ref: './test-package-schemas.yaml#/components/schemas/TestPackage' + $ref: 'test-package-schemas.yaml#/components/schemas/TestPackage' "401": - $ref: './error-responses.yaml#/components/responses/Unauthorized' + $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": - $ref: './error-responses.yaml#/components/responses/Forbidden' + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/db/changelog/000-template.sql b/src/main/resources/db/changelog/0-basis/000-template.sql similarity index 100% rename from src/main/resources/db/changelog/000-template.sql rename to src/main/resources/db/changelog/0-basis/000-template.sql diff --git a/src/main/resources/db/changelog/001-last-row-count.sql b/src/main/resources/db/changelog/0-basis/001-last-row-count.sql similarity index 100% rename from src/main/resources/db/changelog/001-last-row-count.sql rename to src/main/resources/db/changelog/0-basis/001-last-row-count.sql diff --git a/src/main/resources/db/changelog/002-int-to-var.sql b/src/main/resources/db/changelog/0-basis/002-int-to-var.sql similarity index 100% rename from src/main/resources/db/changelog/002-int-to-var.sql rename to src/main/resources/db/changelog/0-basis/002-int-to-var.sql diff --git a/src/main/resources/db/changelog/003-random-in-range.sql b/src/main/resources/db/changelog/0-basis/003-random-in-range.sql similarity index 100% rename from src/main/resources/db/changelog/003-random-in-range.sql rename to src/main/resources/db/changelog/0-basis/003-random-in-range.sql diff --git a/src/main/resources/db/changelog/004-jsonb-changes-delta.sql b/src/main/resources/db/changelog/0-basis/004-jsonb-changes-delta.sql similarity index 100% rename from src/main/resources/db/changelog/004-jsonb-changes-delta.sql rename to src/main/resources/db/changelog/0-basis/004-jsonb-changes-delta.sql diff --git a/src/main/resources/db/changelog/005-uuid-ossp-extension.sql b/src/main/resources/db/changelog/0-basis/005-uuid-ossp-extension.sql similarity index 100% rename from src/main/resources/db/changelog/005-uuid-ossp-extension.sql rename to src/main/resources/db/changelog/0-basis/005-uuid-ossp-extension.sql diff --git a/src/main/resources/db/changelog/006-numeric-hash-functions.sql b/src/main/resources/db/changelog/0-basis/006-numeric-hash-functions.sql similarity index 86% rename from src/main/resources/db/changelog/006-numeric-hash-functions.sql rename to src/main/resources/db/changelog/0-basis/006-numeric-hash-functions.sql index 5e2e2814..13d31931 100644 --- a/src/main/resources/db/changelog/006-numeric-hash-functions.sql +++ b/src/main/resources/db/changelog/0-basis/006-numeric-hash-functions.sql @@ -3,7 +3,7 @@ -- ============================================================================ -- NUMERIC-HASH-FUNCTIONS ---changeset hash:1 endDelimiter:--// +--changeset numeric-hash-functions:1 endDelimiter:--// -- ---------------------------------------------------------------------------- create function bigIntHash(text) returns bigint as $$ diff --git a/src/main/resources/db/changelog/0-basis/007-table-columns.sql b/src/main/resources/db/changelog/0-basis/007-table-columns.sql new file mode 100644 index 00000000..588defba --- /dev/null +++ b/src/main/resources/db/changelog/0-basis/007-table-columns.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql + + +-- ============================================================================ +-- TABLE-COLUMNS-FUNCTION +--changeset table-columns-function:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace function columnsNames( tableName text ) + returns text + stable + language 'plpgsql' as $$ +declare columns text[]; +begin + columns := (select array(select column_name::text + from information_schema.columns + where table_name = tableName)); + return array_to_string(columns, ', '); +end; $$ +--// diff --git a/src/main/resources/db/changelog/0-basis/008-raise-functions.sql b/src/main/resources/db/changelog/0-basis/008-raise-functions.sql new file mode 100644 index 00000000..ad298dc9 --- /dev/null +++ b/src/main/resources/db/changelog/0-basis/008-raise-functions.sql @@ -0,0 +1,31 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset RAISE-FUNCTIONS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Like `RAISE EXCEPTION` ... just as an expression instead of a statement. + */ +create or replace function raiseException(msg text) + returns varchar + language plpgsql as $$ +begin + raise exception using message = msg; +end; $$; +--// + + +-- ============================================================================ +--changeset ASSERT-FUNCTIONS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Like `ASSERT` but as an expression instead of a statement. + */ +create or replace function assertTrue(expectedTrue boolean, msg text) + returns boolean + language plpgsql as $$ +begin + assert expectedTrue, msg; + return expectedTrue; +end; $$; +--// diff --git a/src/main/resources/db/changelog/009-check-environment.sql b/src/main/resources/db/changelog/0-basis/009-check-environment.sql similarity index 100% rename from src/main/resources/db/changelog/009-check-environment.sql rename to src/main/resources/db/changelog/0-basis/009-check-environment.sql diff --git a/src/main/resources/db/changelog/010-context.sql b/src/main/resources/db/changelog/0-basis/010-context.sql similarity index 83% rename from src/main/resources/db/changelog/010-context.sql rename to src/main/resources/db/changelog/0-basis/010-context.sql index 4820cf9c..25c6c48c 100644 --- a/src/main/resources/db/changelog/010-context.sql +++ b/src/main/resources/db/changelog/0-basis/010-context.sql @@ -10,10 +10,10 @@ This function will be overwritten by later changesets. */ create procedure contextDefined( - currentTask varchar, - currentRequest varchar, - currentUser varchar, - assumedRoles varchar + currentTask varchar(127), + currentRequest text, + currentUser varchar(63), + assumedRoles varchar(1023) ) language plpgsql as $$ begin @@ -23,22 +23,27 @@ end; $$; Defines the transaction context. */ create or replace procedure defineContext( - currentTask varchar, - currentRequest varchar = null, - currentUser varchar = null, - assumedRoles varchar = null + currentTask varchar(127), + currentRequest text = null, + currentUser varchar(63) = null, + assumedRoles varchar(1023) = null ) language plpgsql as $$ begin + currentTask := coalesce(currentTask, ''); + assert length(currentTask) <= 127, FORMAT('currentTask must not be longer than 127 characters: "%s"', currentTask); + assert length(currentTask) >= 12, FORMAT('currentTask must be at least 12 characters long: "%s""', currentTask); execute format('set local hsadminng.currentTask to %L', currentTask); currentRequest := coalesce(currentRequest, ''); execute format('set local hsadminng.currentRequest to %L', currentRequest); currentUser := coalesce(currentUser, ''); + assert length(currentUser) <= 63, FORMAT('currentUser must not be longer than 63 characters: "%s"', currentUser); execute format('set local hsadminng.currentUser to %L', currentUser); assumedRoles := coalesce(assumedRoles, ''); + assert length(assumedRoles) <= 1023, FORMAT('assumedRoles must not be longer than 1023 characters: "%s"', assumedRoles); execute format('set local hsadminng.assumedRoles to %L', assumedRoles); call contextDefined(currentTask, currentRequest, currentUser, assumedRoles); @@ -54,11 +59,11 @@ end; $$; Raises exception if not set. */ create or replace function currentTask() - returns varchar(96) + returns varchar(127) stable -- leakproof language plpgsql as $$ declare - currentTask varchar(96); + currentTask varchar(127); begin begin currentTask := current_setting('hsadminng.currentTask'); @@ -82,11 +87,11 @@ end; $$; Raises exception if not set. */ create or replace function currentRequest() - returns varchar(512) + returns text stable -- leakproof language plpgsql as $$ declare - currentRequest varchar(512); + currentRequest text; begin begin currentRequest := current_setting('hsadminng.currentRequest'); @@ -130,22 +135,11 @@ end; $$; or empty array, if not set. */ create or replace function assumedRoles() - returns varchar(63)[] + returns varchar(1023)[] stable -- leakproof language plpgsql as $$ -declare - currentSubject varchar(63); begin - begin - currentSubject := current_setting('hsadminng.assumedRoles'); - exception - when others then - return array []::varchar[]; - end; - if (currentSubject = '') then - return array []::varchar[]; - end if; - return string_to_array(currentSubject, ';'); + return string_to_array(current_setting('hsadminng.assumedRoles', true), ';'); end; $$; create or replace function cleanIdentifier(rawIdentifier varchar) @@ -155,7 +149,7 @@ create or replace function cleanIdentifier(rawIdentifier varchar) declare cleanIdentifier varchar; begin - cleanIdentifier := regexp_replace(rawIdentifier, '[^A-Za-z0-9\-._:]+', '', 'g'); + cleanIdentifier := regexp_replace(rawIdentifier, '[^A-Za-z0-9\-._|]+', '', 'g'); return cleanIdentifier; end; $$; @@ -213,17 +207,17 @@ begin end ; $$; create or replace function currentSubjects() - returns varchar(63)[] + returns varchar(1023)[] stable -- leakproof language plpgsql as $$ declare - assumedRoles varchar(63)[]; + assumedRoles varchar(1023)[]; begin assumedRoles := assumedRoles(); if array_length(assumedRoles, 1) > 0 then - return assumedRoles(); + return assumedRoles; else - return array [currentUser()]::varchar(63)[]; + return array [currentUser()]::varchar(1023)[]; end if; end; $$; diff --git a/src/main/resources/db/changelog/020-audit-log.sql b/src/main/resources/db/changelog/0-basis/020-audit-log.sql similarity index 71% rename from src/main/resources/db/changelog/020-audit-log.sql rename to src/main/resources/db/changelog/0-basis/020-audit-log.sql index 428f1e87..c231814c 100644 --- a/src/main/resources/db/changelog/020-audit-log.sql +++ b/src/main/resources/db/changelog/0-basis/020-audit-log.sql @@ -23,13 +23,12 @@ do $$ */ create table tx_context ( - contextId bigint primary key not null, - txId bigint not null, - txTimestamp timestamp not null, - currentUser varchar(63) not null, -- not the uuid, because users can be deleted - assumedRoles varchar not null, -- not the uuids, because roles can be deleted - currentTask varchar(96) not null, - currentRequest varchar(512) not null + txId xid8 primary key not null, + txTimestamp timestamp not null, + currentUser varchar(63) not null, -- not the uuid, because users can be deleted + assumedRoles varchar(1023) not null, -- not the uuids, because roles can be deleted + currentTask varchar(127) not null, + currentRequest text not null ); create index on tx_context using brin (txTimestamp); @@ -43,7 +42,7 @@ create index on tx_context using brin (txTimestamp); */ create table tx_journal ( - contextId bigint not null references tx_context (contextId), + txId xid8 not null references tx_context (txId), targetTable text not null, targetUuid uuid not null, -- Assumes that all audited tables have a uuid column. targetOp operation not null, @@ -53,6 +52,19 @@ create table tx_journal create index on tx_journal (targetTable, targetUuid); --// +-- ============================================================================ +--changeset audit-TX-JOURNAL-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + A view combining tx_journal with tx_context. + */ +create view tx_journal_v as +select txc.*, txj.targettable, txj.targetop, txj.targetuuid, txj.targetdelta + from tx_journal txj + left join tx_context txc using (txId) + order by txc.txtimestamp; +--// + -- ============================================================================ --changeset audit-TX-JOURNAL-TRIGGER:1 endDelimiter:--// -- ---------------------------------------------------------------------------- @@ -64,31 +76,31 @@ create or replace function tx_journal_trigger() language plpgsql as $$ declare curTask text; - curContextId bigint; + curTxId xid8; begin curTask := currentTask(); - curContextId := txid_current()+bigIntHash(curTask); + curTxId := pg_current_xact_id(); insert - into tx_context (contextId, txId, txTimestamp, currentUser, assumedRoles, currentTask, currentRequest) - values (curContextId, txid_current(), now(), - currentUser(), assumedRoles(), curTask, currentRequest()) + into tx_context (txId, txTimestamp, currentUser, assumedRoles, currentTask, currentRequest) + values ( curTxId, now(), + currentUser(), assumedRoles(), curTask, currentRequest()) on conflict do nothing; case tg_op when 'INSERT' then insert into tx_journal - values (curContextId, + values (curTxId, tg_table_name, new.uuid, tg_op::operation, to_jsonb(new)); when 'UPDATE' then insert into tx_journal - values (curContextId, + values (curTxId, tg_table_name, old.uuid, tg_op::operation, jsonb_changes_delta(to_jsonb(old), to_jsonb(new))); when 'DELETE' then insert into tx_journal - values (curContextId, + values (curTxId, tg_table_name, old.uuid, 'DELETE'::operation, null::jsonb); else raise exception 'Trigger op % not supported for %.', tg_op, tg_table_name; diff --git a/src/main/resources/db/changelog/0-basis/030-historization.sql b/src/main/resources/db/changelog/0-basis/030-historization.sql new file mode 100644 index 00000000..709cb9c8 --- /dev/null +++ b/src/main/resources/db/changelog/0-basis/030-historization.sql @@ -0,0 +1,160 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-global-historization-tx-history-txid:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +create or replace function tx_history_txid() + returns xid8 stable + language plpgsql as $$ +declare + historicalTxIdSetting text; + historicalTimestampSetting text; + historicalTxId xid8; + historicalTimestamp timestamp; +begin + select coalesce(current_setting('hsadminng.tx_history_txid', true), '') into historicalTxIdSetting; + select coalesce(current_setting('hsadminng.tx_history_timestamp', true), '') into historicalTimestampSetting; + if historicalTxIdSetting > '' and historicalTimestampSetting > '' then + raise exception 'either hsadminng.tx_history_txid or hsadminng.tx_history_timestamp must be set, but both are set: (%, %)', + historicalTxIdSetting, historicalTimestampSetting; + end if; + if historicalTxIdSetting = '' and historicalTimestampSetting = '' then + raise exception 'either hsadminng.tx_history_txid or hsadminng.tx_history_timestamp must be set, but both are unset or empty: (%, %)', + historicalTxIdSetting, historicalTimestampSetting; + end if; + -- just for debugging / making sure the function is only called once per query + -- raise notice 'tx_history_txid() called with: (%, %)', historicalTxIdSetting, historicalTimestampSetting; + + if historicalTxIdSetting is null or historicalTxIdSetting = '' then + select historicalTimestampSetting::timestamp into historicalTimestamp; + select max(txc.txid) from tx_context txc where txc.txtimestamp <= historicalTimestamp into historicalTxId; + else + historicalTxId = historicalTxIdSetting::xid8; + end if; + return historicalTxId; +end; $$; +--// + + +-- ============================================================================ +--changeset hs-global-historization-tx-historicize-tf:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create type "tx_operation" as enum ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'); + +create or replace function tx_historicize_tf() + returns trigger + language plpgsql + strict as $$ +declare + currentUser varchar(63); + currentTask varchar(127); + "row" record; + "alive" boolean; + "sql" varchar; +begin + -- determine user_id + begin + currentUser := current_setting('hsadminng.currentUser'); + exception + when others then + currentUser := null; + end; + if (currentUser is null or currentUser = '') then + raise exception 'hsadminng.currentUser must be defined, please use "SET LOCAL ...;"'; + end if; + raise notice 'currentUser: %', currentUser; + + -- determine task + currentTask = current_setting('hsadminng.currentTask'); + assert currentTask is not null and length(currentTask) >= 12, + format('hsadminng.currentTask (%s) must be defined and min 12 characters long, please use "SET LOCAL ...;"', + currentTask); + assert length(currentTask) <= 127, + format('hsadminng.currentTask (%s) must not be longer than 127 characters"', currentTask); + + if (TG_OP = 'INSERT') or (TG_OP = 'UPDATE') then + "row" := NEW; + "alive" := true; + else -- DELETE or TRUNCATE + "row" := OLD; + "alive" := false; + end if; + + sql := format('INSERT INTO %3$I_ex VALUES (DEFAULT, pg_current_xact_id(), %1$L, %2$L, $1.*)', TG_OP, alive, TG_TABLE_NAME); + raise notice 'sql: %', sql; + execute sql using "row"; + + return "row"; +end; $$; +--// + + +-- ============================================================================ +--changeset hs-global-historization-tx-create-historicization:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + + +create or replace procedure tx_create_historicization(baseTable varchar) + language plpgsql as $$ +declare + createHistTableSql varchar; + createTriggerSQL varchar; + viewName varchar; + exVersionsTable varchar; + createViewSQL varchar; + baseCols varchar; +begin + + -- create the history table + createHistTableSql = '' || + 'CREATE TABLE ' || baseTable || '_ex (' || + ' version_id serial PRIMARY KEY,' || + ' txid xid8 NOT NULL REFERENCES tx_context(txid),' || + ' trigger_op tx_operation NOT NULL,' || + ' alive boolean not null,' || + ' LIKE ' || baseTable || + ' EXCLUDING CONSTRAINTS' || + ' EXCLUDING STATISTICS' || + ')'; + raise notice 'sql: %', createHistTableSql; + execute createHistTableSql; + + -- create the historical view + viewName = quote_ident(format('%s_hv', baseTable)); + exVersionsTable = quote_ident(format('%s_ex', baseTable)); + baseCols = (select string_agg(quote_ident(column_name), ', ') + from information_schema.columns + where table_schema = 'public' + and table_name = baseTable); + + createViewSQL = format( + 'CREATE OR REPLACE VIEW %1$s AS' || + '(' || + -- make sure the function is only called once, not for every matching row in tx_context + ' WITH txh AS (SELECT tx_history_txid() AS txid) ' || + ' SELECT %2$s' || + ' FROM %3$s' || + ' WHERE alive = TRUE' || + ' AND version_id IN' || + ' (' || + ' SELECT max(ex.version_id) AS history_id' || + ' FROM %3$s AS ex' || + ' JOIN tx_context as txc ON ex.txid = txc.txid' || + ' WHERE txc.txid <= (SELECT txid FROM txh)' || + ' GROUP BY uuid' || + ' )' || + ')', + viewName, baseCols, exVersionsTable + ); + raise notice 'sql: %', createViewSQL; + execute createViewSQL; + + createTriggerSQL = 'CREATE TRIGGER ' || baseTable || '_tx_historicize_tg' || + ' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable || + ' FOR EACH ROW EXECUTE PROCEDURE tx_historicize_tf()'; + raise notice 'sql: %', createTriggerSQL; + execute createTriggerSQL; + +end; $$; +--// diff --git a/src/main/resources/db/changelog/0-basis/090-log-slow-queries-extensions.sql b/src/main/resources/db/changelog/0-basis/090-log-slow-queries-extensions.sql new file mode 100644 index 00000000..953004db --- /dev/null +++ b/src/main/resources/db/changelog/0-basis/090-log-slow-queries-extensions.sql @@ -0,0 +1,13 @@ +--liquibase formatted sql + + +-- ============================================================================ +-- PG-STAT-STATEMENTS-EXTENSION +--changeset pg-stat-statements-extension:1 context:pg_stat_statements endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Makes improved uuid generation available. + */ +create extension if not exists "pg_stat_statements"; +--// + diff --git a/src/main/resources/db/changelog/057-rbac-role-builder.sql b/src/main/resources/db/changelog/057-rbac-role-builder.sql deleted file mode 100644 index 81a81590..00000000 --- a/src/main/resources/db/changelog/057-rbac-role-builder.sql +++ /dev/null @@ -1,88 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ --- PERMISSIONS ---changeset rbac-role-builder-to-uuids:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -create or replace function toPermissionUuids(forObjectUuid uuid, permitOps RbacOp[]) - returns uuid[] - language plpgsql - strict as $$ -begin - return createPermissions(forObjectUuid, permitOps); -end; $$; - -create or replace function toRoleUuids(roleDescriptors RbacRoleDescriptor[]) - returns uuid[] - language plpgsql - strict as $$ -declare - superRoleDescriptor RbacRoleDescriptor; - superRoleUuids uuid[] := array []::uuid[]; -begin - foreach superRoleDescriptor in array roleDescriptors - loop - if superRoleDescriptor is not null then - superRoleUuids := superRoleUuids || getRoleId(superRoleDescriptor, 'fail'); - end if; - end loop; - - return superRoleUuids; -end; $$; - - --- ================================================================= --- CREATE ROLE ---changeset rbac-role-builder-create-role:1 endDelimiter:--// --- ----------------------------------------------------------------- - -create or replace function createRoleWithGrants( - roleDescriptor RbacRoleDescriptor, - permissions RbacOp[] = array[]::RbacOp[], - incomingSuperRoles RbacRoleDescriptor[] = array[]::RbacRoleDescriptor[], - outgoingSubRoles RbacRoleDescriptor[] = array[]::RbacRoleDescriptor[], - userUuids uuid[] = array[]::uuid[], - grantedByRole RbacRoleDescriptor = null -) - returns uuid - called on null input - language plpgsql as $$ -declare - roleUuid uuid; - superRoleUuid uuid; - subRoleUuid uuid; - userUuid uuid; - grantedByRoleUuid uuid; -begin - roleUuid := createRole(roleDescriptor); - - if cardinality(permissions) >0 then - call grantPermissionsToRole(roleUuid, toPermissionUuids(roleDescriptor.objectuuid, permissions)); - end if; - - foreach superRoleUuid in array toRoleUuids(incomingSuperRoles) - loop - call grantRoleToRole(roleUuid, superRoleUuid); - end loop; - - foreach subRoleUuid in array toRoleUuids(outgoingSubRoles) - loop - call grantRoleToRole(subRoleUuid, roleUuid); - end loop; - - if cardinality(userUuids) > 0 then - if grantedByRole is null then - raise exception 'to directly assign users to roles, grantingRole has to be given'; - end if; - grantedByRoleUuid := getRoleId(grantedByRole, 'fail'); - foreach userUuid in array userUuids - loop - call grantRoleToUserUnchecked(grantedByRoleUuid, roleUuid, userUuid); - end loop; - end if; - - return roleUuid; -end; $$; ---// - diff --git a/src/main/resources/db/changelog/050-rbac-base.sql b/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql similarity index 61% rename from src/main/resources/db/changelog/050-rbac-base.sql rename to src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql index 0f111177..6199abcd 100644 --- a/src/main/resources/db/changelog/050-rbac-base.sql +++ b/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql @@ -86,29 +86,6 @@ create or replace function findRbacUserId(userName varchar) language sql as $$ select uuid from RbacUser where name = userName $$; - -create type RbacWhenNotExists as enum ('fail', 'create'); - -create or replace function getRbacUserId(userName varchar, whenNotExists RbacWhenNotExists) - returns uuid - returns null on null input - language plpgsql as $$ -declare - userUuid uuid; -begin - userUuid = findRbacUserId(userName); - if (userUuid is null) then - if (whenNotExists = 'fail') then - raise exception 'RbacUser with name="%" not found', userName; - end if; - if (whenNotExists = 'create') then - userUuid = createRbacUser(userName); - end if; - end if; - return userUuid; -end; -$$; - --// -- ============================================================================ @@ -120,6 +97,7 @@ $$; create table RbacObject ( uuid uuid primary key default uuid_generate_v4(), + serialId serial, -- TODO.perf: only needed for reverse deletion of temp test data objectTable varchar(64) not null, unique (objectTable, uuid) ); @@ -186,7 +164,7 @@ end; $$; */ -create type RbacRoleType as enum ('owner', 'admin', 'agent', 'tenant', 'guest'); +create type RbacRoleType as enum ('OWNER', 'ADMIN', 'AGENT', 'TENANT', 'GUEST', 'REFERRER'); create table RbacRole ( @@ -202,15 +180,33 @@ create type RbacRoleDescriptor as ( objectTable varchar(63), -- for human readability and easier debugging objectUuid uuid, - roleType RbacRoleType + roleType RbacRoleType, + assumed boolean ); -create or replace function roleDescriptor(objectTable varchar(63), objectUuid uuid, roleType RbacRoleType) +create or replace function assumed() + returns boolean + stable -- leakproof + language sql as $$ + select true; +$$; + +create or replace function unassumed() + returns boolean + stable -- leakproof + language sql as $$ +select false; +$$; + + +create or replace function roleDescriptor( + objectTable varchar(63), objectUuid uuid, roleType RbacRoleType, + assumed boolean = true) -- just for DSL readability, belongs actually to the grant returns RbacRoleDescriptor returns null on null input stable -- leakproof language sql as $$ -select objectTable, objectUuid, roleType::RbacRoleType; + select objectTable, objectUuid, roleType::RbacRoleType, assumed; $$; create or replace function createRole(roleDescriptor RbacRoleDescriptor) @@ -252,8 +248,8 @@ declare objectUuidOfRole uuid; roleUuid uuid; begin - -- TODO.refact: extract function toRbacRoleDescriptor(roleIdName varchar) + find other occurrences - roleParts = overlay(roleIdName placing '#' from length(roleIdName) + 1 - strpos(reverse(roleIdName), '.')); + -- TODO.refa: extract function toRbacRoleDescriptor(roleIdName varchar) + find other occurrences + roleParts = overlay(roleIdName placing '#' from length(roleIdName) + 1 - strpos(reverse(roleIdName), ':')); objectTableFromRoleIdName = split_part(roleParts, '#', 1); objectNameFromRoleIdName = split_part(roleParts, '#', 2); roleTypeFromRoleIdName = split_part(roleParts, '#', 3); @@ -274,21 +270,17 @@ create or replace function findRoleId(roleDescriptor RbacRoleDescriptor) select uuid from RbacRole where objectUuid = roleDescriptor.objectUuid and roleType = roleDescriptor.roleType; $$; -create or replace function getRoleId(roleDescriptor RbacRoleDescriptor, whenNotExists RbacWhenNotExists) +create or replace function getRoleId(roleDescriptor RbacRoleDescriptor) returns uuid - returns null on null input language plpgsql as $$ declare roleUuid uuid; begin - roleUuid = findRoleId(roleDescriptor); + assert roleDescriptor is not null, 'roleDescriptor must not be null'; + + roleUuid := findRoleId(roleDescriptor); if (roleUuid is null) then - if (whenNotExists = 'fail') then - raise exception 'RbacRole "%#%.%" not found', roleDescriptor.objectTable, roleDescriptor.objectUuid, roleDescriptor.roleType; - end if; - if (whenNotExists = 'create') then - roleUuid = createRole(roleDescriptor); - end if; + raise exception 'RbacRole "%#%.%" not found', roleDescriptor.objectTable, roleDescriptor.objectUuid, roleDescriptor.roleType; end if; return roleUuid; end; @@ -364,72 +356,68 @@ create trigger deleteRbacRolesOfRbacObject_Trigger /* */ -create domain RbacOp as varchar(67) +create domain RbacOp as varchar(6) check ( - VALUE = '*' - or VALUE = 'delete' - or VALUE = 'edit' - or VALUE = 'view' - or VALUE = 'assume' - or VALUE ~ '^add-[a-z]+$' - or VALUE ~ '^new-[a-z-]+$' + VALUE = 'DELETE' + or VALUE = 'UPDATE' + or VALUE = 'SELECT' + or VALUE = 'INSERT' + or VALUE = 'ASSUME' ); create table RbacPermission ( - uuid uuid primary key references RbacReference (uuid) on delete cascade, - objectUuid uuid not null references RbacObject, - op RbacOp not null, - unique (objectUuid, op) + uuid uuid primary key references RbacReference (uuid) on delete cascade, + objectUuid uuid not null references RbacObject, + op RbacOp not null, + opTableName varchar(60) ); +-- TODO.perf: check if these indexes are really useful +create index on RbacPermission (objectUuid, op); +create index on RbacPermission (opTableName, op); + +ALTER TABLE RbacPermission + ADD CONSTRAINT RbacPermission_uc UNIQUE NULLS NOT DISTINCT (objectUuid, op, opTableName); call create_journal('RbacPermission'); -create or replace function permissionExists(forObjectUuid uuid, forOp RbacOp) - returns bool - language sql as $$ -select exists( - select op - from RbacPermission p - where p.objectUuid = forObjectUuid - and p.op in ('*', forOp) - ); -$$; - -create or replace function createPermissions(forObjectUuid uuid, permitOps RbacOp[]) - returns uuid[] +create or replace function createPermission(forObjectUuid uuid, forOp RbacOp, forOpTableName text = null) + returns uuid language plpgsql as $$ declare - refId uuid; - permissionIds uuid[] = array []::uuid[]; + permissionUuid uuid; begin if (forObjectUuid is null) then raise exception 'forObjectUuid must not be null'; end if; - if (array_length(permitOps, 1) > 1 and '*' = any (permitOps)) then - raise exception '"*" operation must not be assigned along with other operations: %', permitOps; + if (forOp = 'INSERT' and forOpTableName is null) then + raise exception 'INSERT permissions needs forOpTableName'; + end if; + if (forOp <> 'INSERT' and forOpTableName is not null) then + raise exception 'forOpTableName must only be specified for ops: [INSERT]'; -- currently no other end if; - for i in array_lower(permitOps, 1)..array_upper(permitOps, 1) - loop - refId = (select uuid from RbacPermission where objectUuid = forObjectUuid and op = permitOps[i]); - if (refId is null) then - insert - into RbacReference ("type") - values ('RbacPermission') - returning uuid into refId; - insert - into RbacPermission (uuid, objectUuid, op) - values (refId, forObjectUuid, permitOps[i]); - end if; - permissionIds = permissionIds || refId; - end loop; + permissionUuid := ( + select uuid from RbacPermission + where objectUuid = forObjectUuid + and op = forOp and opTableName is not distinct from forOpTableName); + if (permissionUuid is null) then + insert into RbacReference ("type") + values ('RbacPermission') + returning uuid into permissionUuid; + begin + insert into RbacPermission (uuid, objectUuid, op, opTableName) + values (permissionUuid, forObjectUuid, forOp, forOpTableName); + exception + when others then + raise exception 'insert into RbacPermission (uuid, objectUuid, op, opTableName) + values (%, %, %, %);', permissionUuid, forObjectUuid, forOp, forOpTableName; + end; + end if; + return permissionUuid; +end; $$; - return permissionIds; -end; -$$; - -create or replace function findPermissionId(forObjectUuid uuid, forOp RbacOp) +create or replace function findEffectivePermissionId(forObjectUuid uuid, forOp RbacOp, forOpTableName text = null) returns uuid returns null on null input stable -- leakproof @@ -437,11 +425,59 @@ create or replace function findPermissionId(forObjectUuid uuid, forOp RbacOp) select uuid from RbacPermission p where p.objectUuid = forObjectUuid - and p.op in ('*', forOp) + and (forOp = 'SELECT' or p.op = forOp) -- all other RbacOp include 'SELECT' + and p.opTableName = forOpTableName $$; +create or replace function findPermissionId(forObjectUuid uuid, forOp RbacOp, forOpTableName text = null) + returns uuid + returns null on null input + stable -- leakproof + language sql as $$ +select uuid + from RbacPermission p + where p.objectUuid = forObjectUuid + and p.op = forOp + and p.opTableName = forOpTableName +$$; + +create or replace function getPermissionId(forObjectUuid uuid, forOp RbacOp, forOpTableName text = null) + returns uuid + stable -- leakproof + language plpgsql as $$ +declare + permissionUuid uuid; +begin + select uuid into permissionUuid + from RbacPermission p + where p.objectUuid = forObjectUuid + and p.op = forOp + and forOpTableName is null or p.opTableName = forOpTableName; + assert permissionUuid is not null, + format('permission %s %s for object UUID %s cannot be found', forOp, forOpTableName, forObjectUuid); + return permissionUuid; +end; $$; --// + +-- ============================================================================ +--changeset rbac-base-duplicate-role-grant-exception:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace procedure raiseDuplicateRoleGrantException(subRoleId uuid, superRoleId uuid) + language plpgsql as $$ +declare + subRoleIdName text; + superRoleIdName text; +begin + select roleIdName from rbacRole_ev where uuid=subRoleId into subRoleIdName; + select roleIdName from rbacRole_ev where uuid=superRoleId into superRoleIdName; + raise exception '[400] Duplicate role grant detected: role % (%) already granted to % (%)', subRoleId, subRoleIdName, superRoleId, superRoleIdName; +end; +$$; +--// + + -- ============================================================================ --changeset rbac-base-GRANTS:1 endDelimiter:--// -- ---------------------------------------------------------------------------- @@ -451,88 +487,91 @@ $$; create table RbacGrants ( uuid uuid primary key default uuid_generate_v4(), + grantedByTriggerOf uuid references RbacObject (uuid) on delete cascade initially deferred , grantedByRoleUuid uuid references RbacRole (uuid), ascendantUuid uuid references RbacReference (uuid), descendantUuid uuid references RbacReference (uuid), assumed boolean not null default true, -- auto assumed (true) vs. needs assumeRoles (false) - unique (ascendantUuid, descendantUuid) -); + unique (ascendantUuid, descendantUuid), + constraint rbacGrant_createdBy check ( grantedByRoleUuid is null or grantedByTriggerOf is null) ); create index on RbacGrants (ascendantUuid); create index on RbacGrants (descendantUuid); call create_journal('RbacGrants'); - create or replace function findGrantees(grantedId uuid) returns setof RbacReference returns null on null input language sql as $$ -select reference.* - from (with recursive grants as (select descendantUuid, - ascendantUuid - from RbacGrants - where descendantUuid = grantedId - union all - select "grant".descendantUuid, - "grant".ascendantUuid - from RbacGrants "grant" - inner join grants recur on recur.ascendantUuid = "grant".descendantUuid) - select ascendantUuid - from grants) as grantee - join RbacReference reference on reference.uuid = grantee.ascendantUuid; +with recursive grants as ( + select descendantUuid, ascendantUuid + from RbacGrants + where descendantUuid = grantedId + union all + select g.descendantUuid, g.ascendantUuid + from RbacGrants g + inner join grants on grants.ascendantUuid = g.descendantUuid +) +select ref.* + from grants + join RbacReference ref on ref.uuid = grants.ascendantUuid; +$$; + +create or replace function isGranted(granteeIds uuid[], grantedId uuid) + returns bool + returns null on null input + language sql as $$ +with recursive grants as ( + select descendantUuid, ascendantUuid + from RbacGrants + where descendantUuid = grantedId + union all + select "grant".descendantUuid, "grant".ascendantUuid + from RbacGrants "grant" + inner join grants recur on recur.ascendantUuid = "grant".descendantUuid +) +select exists ( + select true + from grants + where ascendantUuid = any(granteeIds) +) or grantedId = any(granteeIds); $$; create or replace function isGranted(granteeId uuid, grantedId uuid) returns bool returns null on null input language sql as $$ -select granteeId = grantedId or granteeId in (with recursive grants as (select descendantUuid, ascendantUuid - from RbacGrants - where descendantUuid = grantedId - union all - select "grant".descendantUuid, "grant".ascendantUuid - from RbacGrants "grant" - inner join grants recur on recur.ascendantUuid = "grant".descendantUuid) - select ascendantUuid - from grants); +select * from isGranted(array[granteeId], grantedId); $$; - -create or replace function isGranted(granteeIds uuid[], grantedId uuid) - returns bool - returns null on null input - language plpgsql as $$ -declare - granteeId uuid; -begin - -- TODO.perf: needs optimization - foreach granteeId in array granteeIds - loop - if isGranted(granteeId, grantedId) then - return true; - end if; - end loop; - return false; -end; $$; - create or replace function isPermissionGrantedToSubject(permissionId uuid, subjectId uuid) returns BOOL stable -- leakproof language sql as $$ +with recursive grants as ( + select descendantUuid, ascendantUuid + from RbacGrants + where descendantUuid = permissionId + union all + select g.descendantUuid, g.ascendantUuid + from RbacGrants g + inner join grants on grants.ascendantUuid = g.descendantUuid +) select exists( - select * - from RbacUser - where uuid in (with recursive grants as (select descendantUuid, - ascendantUuid - from RbacGrants g - where g.descendantUuid = permissionId - union all - select g.descendantUuid, - g.ascendantUuid - from RbacGrants g - inner join grants recur on recur.ascendantUuid = g.descendantUuid) - select ascendantUuid - from grants - where ascendantUuid = subjectId) - ); + select true + from grants + where ascendantUuid = subjectId +); +$$; + +create or replace function hasInsertPermission(objectUuid uuid, tableName text ) + returns BOOL + stable -- leakproof + language plpgsql as $$ +declare + permissionUuid uuid; +begin + permissionUuid = findPermissionId(objectUuid, 'INSERT'::RbacOp, tableName); + return permissionUuid is not null; +end; $$; create or replace function hasGlobalRoleGranted(userUuid uuid) @@ -549,21 +588,23 @@ select exists( ); $$; -create or replace procedure grantPermissionsToRole(roleUuid uuid, permissionIds uuid[]) +create or replace procedure grantPermissionToRole(permissionUuid uuid, roleUuid uuid) language plpgsql as $$ begin - if cardinality(permissionIds) = 0 then return; end if; + perform assertReferenceType('roleId (ascendant)', roleUuid, 'RbacRole'); + perform assertReferenceType('permissionId (descendant)', permissionUuid, 'RbacPermission'); - for i in array_lower(permissionIds, 1)..array_upper(permissionIds, 1) - loop - perform assertReferenceType('roleId (ascendant)', roleUuid, 'RbacRole'); - perform assertReferenceType('permissionId (descendant)', permissionIds[i], 'RbacPermission'); + insert + into RbacGrants (grantedByTriggerOf, ascendantUuid, descendantUuid, assumed) + values (currentTriggerObjectUuid(), roleUuid, permissionUuid, true) + on conflict do nothing; -- allow granting multiple times +end; +$$; - insert - into RbacGrants (ascendantUuid, descendantUuid, assumed) - values (roleUuid, permissionIds[i], true) - on conflict do nothing; -- allow granting multiple times - end loop; +create or replace procedure grantPermissionToRole(permissionUuid uuid, roleDesc RbacRoleDescriptor) + language plpgsql as $$ +begin + call grantPermissionToRole(permissionUuid, findRoleId(roleDesc)); end; $$; @@ -574,12 +615,12 @@ begin perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); if isGranted(subRoleId, superRoleId) then - raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId; + call raiseDuplicateRoleGrantException(subRoleId, superRoleId); end if; insert - into RbacGrants (ascendantuuid, descendantUuid, assumed) - values (superRoleId, subRoleId, doAssume) + into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) + values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing; -- allow granting multiple times end; $$; @@ -590,6 +631,11 @@ declare superRoleId uuid; subRoleId uuid; begin + -- TODO.refa: maybe separate method grantRoleToRoleIfNotNull(...) for NULLABLE references + if superRole.objectUuid is null or subRole.objectuuid is null then + return; + end if; + superRoleId := findRoleId(superRole); subRoleId := findRoleId(subRole); @@ -597,35 +643,12 @@ begin perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); if isGranted(subRoleId, superRoleId) then - raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId; + call raiseDuplicateRoleGrantException(subRoleId, superRoleId); end if; insert - into RbacGrants (ascendantuuid, descendantUuid, assumed) - values (superRoleId, subRoleId, doAssume) - on conflict do nothing; -- allow granting multiple times -end; $$; - -create or replace procedure grantRoleToRoleIfNotNull(subRole RbacRoleDescriptor, superRole RbacRoleDescriptor, doAssume bool = true) - language plpgsql as $$ -declare - superRoleId uuid; - subRoleId uuid; -begin - superRoleId := findRoleId(superRole); - if ( subRoleId is null ) then return; end if; - subRoleId := findRoleId(subRole); - - perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole'); - perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); - - if isGranted(subRoleId, superRoleId) then - raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId; - end if; - - insert - into RbacGrants (ascendantuuid, descendantUuid, assumed) - values (superRoleId, subRoleId, doAssume) + into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) + values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing; -- allow granting multiple times end; $$; @@ -644,20 +667,48 @@ begin if (isGranted(superRoleId, subRoleId)) then delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId; else - raise exception 'cannot revoke role % (%) from % (% because it is not granted', + raise exception 'cannot revoke role % (%) from % (%) because it is not granted', subRole, subRoleId, superRole, superRoleId; end if; end; $$; +create or replace procedure revokePermissionFromRole(permissionId UUID, superRole RbacRoleDescriptor) + language plpgsql as $$ +declare + superRoleId uuid; + permissionOp text; + objectTable text; + objectUuid uuid; +begin + superRoleId := findRoleId(superRole); + + perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole'); + perform assertReferenceType('permission (descendant)', permissionId, 'RbacPermission'); + + if (isGranted(superRoleId, permissionId)) then + delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = permissionId; + else + select p.op, o.objectTable, o.uuid + from rbacGrants g + join rbacPermission p on p.uuid=g.descendantUuid + join rbacobject o on o.uuid=p.objectUuid + where g.uuid=permissionId + into permissionOp, objectTable, objectUuid; + + raise exception 'cannot revoke permission % (% on %#% (%) from % (%)) because it is not granted', + permissionId, permissionOp, objectTable, objectUuid, permissionId, superRole, superRoleId; + end if; +end; $$; + -- ============================================================================ ---changeset rbac-base-QUERY-ACCESSIBLE-OBJECT-UUIDS:1 endDelimiter:--// +--changeset rbac-base-QUERY-ACCESSIBLE-OBJECT-UUIDS:1 runOnChange=true endDelimiter:--// -- ---------------------------------------------------------------------------- /* */ create or replace function queryAccessibleObjectUuidsOfSubjectIds( requiredOp RbacOp, - forObjectTable varchar, -- reduces the result set, but is not really faster when used in restricted view + forObjectTable varchar, subjectIds uuid[], maxObjects integer = 8000) returns setof uuid @@ -666,23 +717,29 @@ create or replace function queryAccessibleObjectUuidsOfSubjectIds( declare foundRows bigint; begin - return query select distinct perm.objectUuid - from (with recursive grants as (select descendantUuid, ascendantUuid, 1 as level - from RbacGrants - where assumed - and ascendantUuid = any (subjectIds) - union - distinct - select "grant".descendantUuid, "grant".ascendantUuid, level + 1 as level - from RbacGrants "grant" - inner join grants recur on recur.descendantUuid = "grant".ascendantUuid - where assumed) - select descendantUuid - from grants) as granted - join RbacPermission perm - on granted.descendantUuid = perm.uuid and perm.op in ('*', requiredOp) - join RbacObject obj on obj.uuid = perm.objectUuid and obj.objectTable = forObjectTable - limit maxObjects + 1; + return query + WITH RECURSIVE grants AS ( + SELECT descendantUuid, ascendantUuid, 1 AS level + FROM RbacGrants + WHERE assumed + AND ascendantUuid = any(subjectIds) + UNION ALL + SELECT g.descendantUuid, g.ascendantUuid, grants.level + 1 AS level + FROM RbacGrants g + INNER JOIN grants ON grants.descendantUuid = g.ascendantUuid + WHERE g.assumed + ), + granted AS ( + SELECT DISTINCT descendantUuid + FROM grants + ) + SELECT DISTINCT perm.objectUuid + FROM granted + JOIN RbacPermission perm ON granted.descendantUuid = perm.uuid + JOIN RbacObject obj ON obj.uuid = perm.objectUuid + WHERE (requiredOp = 'SELECT' OR perm.op = requiredOp) + AND obj.objectTable = forObjectTable + LIMIT maxObjects+1; foundRows = lastRowCount(); if foundRows > maxObjects then @@ -693,7 +750,6 @@ begin end if; end; $$; - --// -- ============================================================================ @@ -706,24 +762,23 @@ create or replace function queryPermissionsGrantedToSubjectId(subjectId uuid) returns setof RbacPermission strict language sql as $$ - -- @formatter:off -select * - from RbacPermission - where uuid in ( - with recursive grants as ( - select distinct descendantUuid, ascendantUuid - from RbacGrants - where ascendantUuid = subjectId - union all - select "grant".descendantUuid, "grant".ascendantUuid - from RbacGrants "grant" - inner join grants recur on recur.descendantUuid = "grant".ascendantUuid - ) - select descendantUuid - from grants - ); --- @formatter:on +with recursive grants as ( + select descendantUuid, ascendantUuid + from RbacGrants + where ascendantUuid = subjectId + union all + select g.descendantUuid, g.ascendantUuid + from RbacGrants g + inner join grants on grants.descendantUuid = g.ascendantUuid +) +select perm.* + from RbacPermission perm + where perm.uuid in ( + select descendantUuid + from grants + ); $$; + --// -- ============================================================================ @@ -772,6 +827,5 @@ do $$ create role restricted; grant all privileges on all tables in schema public to restricted; end if; - end $$ + end $$; --// - diff --git a/src/main/resources/db/changelog/051-rbac-user-grant.sql b/src/main/resources/db/changelog/1-rbac/1051-rbac-user-grant.sql similarity index 67% rename from src/main/resources/db/changelog/051-rbac-user-grant.sql rename to src/main/resources/db/changelog/1-rbac/1051-rbac-user-grant.sql index 23dcbdd4..fc74a6de 100644 --- a/src/main/resources/db/changelog/051-rbac-user-grant.sql +++ b/src/main/resources/db/changelog/1-rbac/1051-rbac-user-grant.sql @@ -20,40 +20,50 @@ begin return currentSubjectsUuids[1]; end; $$; -create or replace procedure grantRoleToUserUnchecked(grantedByRoleUuid uuid, roleUuid uuid, userUuid uuid, doAssume boolean = true) +create or replace procedure grantRoleToUserUnchecked(grantedByRoleUuid uuid, grantedRoleUuid uuid, userUuid uuid, doAssume boolean = true) language plpgsql as $$ begin perform assertReferenceType('grantingRoleUuid', grantedByRoleUuid, 'RbacRole'); - perform assertReferenceType('roleId (descendant)', roleUuid, 'RbacRole'); + perform assertReferenceType('roleId (descendant)', grantedRoleUuid, 'RbacRole'); perform assertReferenceType('userId (ascendant)', userUuid, 'RbacUser'); insert into RbacGrants (grantedByRoleUuid, ascendantUuid, descendantUuid, assumed) - values (grantedByRoleUuid, userUuid, roleUuid, doAssume); - -- TODO.spec: What should happen on mupltiple grants? What if options (doAssume) are not the same? - -- Most powerful or latest grant wins? What about managed? - -- on conflict do nothing; -- allow granting multiple times + values (grantedByRoleUuid, userUuid, grantedRoleUuid, doAssume) + -- TODO: check if grantedByRoleUuid+doAssume are the same, otherwise raise exception? + on conflict do nothing; -- allow granting multiple times end; $$; create or replace procedure grantRoleToUser(grantedByRoleUuid uuid, grantedRoleUuid uuid, userUuid uuid, doAssume boolean = true) language plpgsql as $$ +declare + grantedByRoleIdName text; + grantedRoleIdName text; begin perform assertReferenceType('grantingRoleUuid', grantedByRoleUuid, 'RbacRole'); perform assertReferenceType('grantedRoleUuid (descendant)', grantedRoleUuid, 'RbacRole'); perform assertReferenceType('userUuid (ascendant)', userUuid, 'RbacUser'); - if NOT isGranted(currentSubjectsUuids(), grantedByRoleUuid) then - raise exception '[403] Access to granted-by-role % forbidden for %', grantedByRoleUuid, currentSubjects(); - end if; + assert grantedByRoleUuid is not null, 'grantedByRoleUuid must not be null'; + assert grantedRoleUuid is not null, 'grantedRoleUuid must not be null'; + assert userUuid is not null, 'userUuid must not be null'; + if NOT isGranted(currentSubjectsUuids(), grantedByRoleUuid) then + select roleIdName from rbacRole_ev where uuid=grantedByRoleUuid into grantedByRoleIdName; + raise exception '[403] Access to granted-by-role % (%) forbidden for % (%)', + grantedByRoleIdName, grantedByRoleUuid, currentSubjects(), currentSubjectsUuids(); + end if; if NOT isGranted(grantedByRoleUuid, grantedRoleUuid) then - raise exception '[403] Access to granted role % forbidden for %', grantedRoleUuid, currentSubjects(); + select roleIdName from rbacRole_ev where uuid=grantedByRoleUuid into grantedByRoleIdName; + select roleIdName from rbacRole_ev where uuid=grantedRoleUuid into grantedRoleIdName; + raise exception '[403] Access to granted role % (%) forbidden for % (%)', + grantedRoleIdName, grantedRoleUuid, grantedByRoleIdName, grantedByRoleUuid; end if; insert into RbacGrants (grantedByRoleUuid, ascendantUuid, descendantUuid, assumed) values (grantedByRoleUuid, userUuid, grantedRoleUuid, doAssume); - -- TODO.spec: What should happen on mupltiple grants? What if options (doAssume) are not the same? + -- TODO.impl: What should happen on mupltiple grants? What if options (doAssume) are not the same? -- Most powerful or latest grant wins? What about managed? -- on conflict do nothing; -- allow granting multiple times end; $$; @@ -99,4 +109,17 @@ begin where g.ascendantUuid = userUuid and g.descendantUuid = grantedRoleUuid and g.grantedByRoleUuid = revokeRoleFromUser.grantedByRoleUuid; end; $$; ---/ +--// + +-- ============================================================================ +--changeset rbac-user-grant-REVOKE-PERMISSION-FROM-ROLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace procedure revokePermissionFromRole(permissionUuid uuid, superRoleUuid uuid) + language plpgsql as $$ +begin + raise INFO 'delete from RbacGrants where ascendantUuid = % and descendantUuid = %', superRoleUuid, permissionUuid; + delete from RbacGrants as g + where g.ascendantUuid = superRoleUuid and g.descendantUuid = permissionUuid; +end; $$; +--// diff --git a/src/main/resources/db/changelog/054-rbac-context.sql b/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql similarity index 92% rename from src/main/resources/db/changelog/054-rbac-context.sql rename to src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql index ede86057..ab3a9bd5 100644 --- a/src/main/resources/db/changelog/054-rbac-context.sql +++ b/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql @@ -50,20 +50,23 @@ begin foreach roleName in array string_to_array(assumedRoles, ';') loop - roleNameParts = overlay(roleName placing '#' from length(roleName) + 1 - strpos(reverse(roleName), '.')); + roleNameParts = overlay(roleName placing '#' from length(roleName) + 1 - strpos(reverse(roleName), ':')); objectTableToAssume = split_part(roleNameParts, '#', 1); objectNameToAssume = split_part(roleNameParts, '#', 2); roleTypeToAssume = split_part(roleNameParts, '#', 3); objectUuidToAssume = findObjectUuidByIdName(objectTableToAssume, objectNameToAssume); + if objectUuidToAssume is null then + raise exception '[401] object % cannot be found in table %', objectNameToAssume, objectTableToAssume; + end if; - select uuid as roleuuidToAssume + select uuid from RbacRole r where r.objectUuid = objectUuidToAssume and r.roleType = roleTypeToAssume into roleUuidToAssume; if roleUuidToAssume is null then - raise exception '[403] role % not accessible for user %', roleName, currentSubjects(); + raise exception '[403] role % does not exist or is not accessible for user %', roleName, currentUser(); end if; if not isGranted(currentUserUuid, roleUuidToAssume) then raise exception '[403] user % has no permission to assume role %', currentUser(), roleName; @@ -82,10 +85,10 @@ end; $$; This function will be overwritten by later changesets. */ create or replace procedure contextDefined( - currentTask varchar, - currentRequest varchar, - currentUser varchar, - assumedRoles varchar + currentTask varchar(127), + currentRequest text, + currentUser varchar(63), + assumedRoles varchar(1023) ) language plpgsql as $$ declare diff --git a/src/main/resources/db/changelog/055-rbac-views.sql b/src/main/resources/db/changelog/1-rbac/1055-rbac-views.sql similarity index 84% rename from src/main/resources/db/changelog/055-rbac-views.sql rename to src/main/resources/db/changelog/1-rbac/1055-rbac-views.sql index d1d1d926..a8570f6c 100644 --- a/src/main/resources/db/changelog/055-rbac-views.sql +++ b/src/main/resources/db/changelog/1-rbac/1055-rbac-views.sql @@ -9,7 +9,7 @@ */ drop view if exists rbacrole_ev; create or replace view rbacrole_ev as -select (objectTable || '#' || objectIdName || '.' || roleType) as roleIdName, * +select (objectTable || '#' || objectIdName || ':' || roleType) as roleIdName, * -- @formatter:off from ( select r.*, @@ -40,7 +40,7 @@ select * where isGranted(currentSubjectsUuids(), r.uuid) ) as unordered -- @formatter:on - order by objectTable || '#' || objectIdName || '.' || roleType; + order by objectTable || '#' || objectIdName || ':' || roleType; grant all privileges on rbacrole_rv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; --// @@ -56,29 +56,36 @@ drop view if exists rbacgrants_ev; create or replace view rbacgrants_ev as -- @formatter:off select x.grantUuid as uuid, - go.objectTable || '#' || findIdNameByObjectUuid(go.objectTable, go.uuid) || '.' || r.roletype as grantedByRoleIdName, + x.grantedByTriggerOf as grantedByTriggerOf, + go.objectTable || '#' || findIdNameByObjectUuid(go.objectTable, go.uuid) || ':' || r.roletype as grantedByRoleIdName, x.ascendingIdName as ascendantIdName, x.descendingIdName as descendantIdName, x.grantedByRoleUuid, x.ascendantUuid as ascendantUuid, x.descendantUuid as descendantUuid, + x.op as permOp, x.optablename as permOpTableName, x.assumed from ( select g.uuid as grantUuid, + g.grantedbytriggerof as grantedbytriggerof, g.grantedbyroleuuid, g.ascendantuuid, g.descendantuuid, g.assumed, coalesce( - 'user ' || au.name, - 'role ' || aro.objectTable || '#' || findIdNameByObjectUuid(aro.objectTable, aro.uuid) || '.' || ar.roletype + 'user:' || au.name, + 'role:' || aro.objectTable || '#' || findIdNameByObjectUuid(aro.objectTable, aro.uuid) || ':' || ar.roletype ) as ascendingIdName, aro.objectTable, aro.uuid, - - coalesce( - 'role ' || dro.objectTable || '#' || findIdNameByObjectUuid(dro.objectTable, dro.uuid) || '.' || dr.roletype, - 'perm ' || dp.op || ' on ' || dpo.objecttable || '#' || findIdNameByObjectUuid(dpo.objectTable, dpo.uuid) + ( case + when dro is not null + then ('role:' || dro.objectTable || '#' || findIdNameByObjectUuid(dro.objectTable, dro.uuid) || ':' || dr.roletype) + when dp.op = 'INSERT' + then 'perm:' || dpo.objecttable || '#' || findIdNameByObjectUuid(dpo.objectTable, dpo.uuid) || ':' || dp.op || '>' || dp.opTableName + else 'perm:' || dpo.objecttable || '#' || findIdNameByObjectUuid(dpo.objectTable, dpo.uuid) || ':' || dp.op + end ) as descendingIdName, - dro.objectTable, dro.uuid - from rbacgrants as g + dro.objectTable, dro.uuid, + dp.op, dp.optablename + from rbacgrants as g left outer join rbacrole as ar on ar.uuid = g.ascendantUuid left outer join rbacobject as aro on aro.uuid = ar.objectuuid @@ -108,8 +115,8 @@ create or replace view rbacgrants_ev as drop view if exists rbacgrants_rv; create or replace view rbacgrants_rv as -- @formatter:off -select o.objectTable || '#' || findIdNameByObjectUuid(o.objectTable, o.uuid) || '.' || r.roletype as grantedByRoleIdName, - g.objectTable || '#' || g.objectIdName || '.' || g.roletype as grantedRoleIdName, g.userName, g.assumed, +select o.objectTable || '#' || findIdNameByObjectUuid(o.objectTable, o.uuid) || ':' || r.roletype as grantedByRoleIdName, + g.objectTable || '#' || g.objectIdName || ':' || g.roletype as grantedRoleIdName, g.userName, g.assumed, g.grantedByRoleUuid, g.descendantUuid as grantedRoleUuid, g.ascendantUuid as userUuid, g.objectTable, g.objectUuid, g.objectIdName, g.roleType as grantedRoleType from ( @@ -320,7 +327,7 @@ execute function deleteRbacUser(); drop view if exists RbacOwnGrantedPermissions_rv; create or replace view RbacOwnGrantedPermissions_rv as select r.uuid as roleuuid, p.uuid as permissionUuid, - (r.objecttable || '#' || r.objectidname || '.' || r.roletype) as roleName, p.op, + (r.objecttable || ':' || r.objectidname || ':' || r.roletype) as roleName, p.op, o.objecttable, r.objectidname, o.uuid as objectuuid from rbacrole_rv r join rbacgrants g on g.ascendantuuid = r.uuid @@ -335,11 +342,9 @@ grant all privileges on RbacOwnGrantedPermissions_rv to ${HSADMINNG_POSTGRES_RES /* Returns all permissions granted to the given user, which are also visible to the current user or assumed roles. - - - */ -create or replace function grantedPermissions(targetUserUuid uuid) - returns table(roleUuid uuid, roleName text, permissionUuid uuid, op RbacOp, objectTable varchar, objectIdName varchar, objectUuid uuid) +*/ +create or replace function grantedPermissionsRaw(targetUserUuid uuid) + returns table(roleUuid uuid, roleName text, permissionUuid uuid, op RbacOp, opTableName varchar(60), objectTable varchar(60), objectIdName varchar, objectUuid uuid) returns null on null input language plpgsql as $$ declare @@ -354,12 +359,14 @@ begin return query select xp.roleUuid, - (xp.roleObjectTable || '#' || xp.roleObjectIdName || '.' || xp.roleType) as roleName, - xp.permissionUuid, xp.op, xp.permissionObjectTable, xp.permissionObjectIdName, xp.permissionObjectUuid + (xp.roleObjectTable || '#' || xp.roleObjectIdName || ':' || xp.roleType) as roleName, + xp.permissionUuid, xp.op, xp.opTableName, + xp.permissionObjectTable, xp.permissionObjectIdName, xp.permissionObjectUuid from (select r.uuid as roleUuid, r.roletype, ro.objectTable as roleObjectTable, findIdNameByObjectUuid(ro.objectTable, ro.uuid) as roleObjectIdName, - p.uuid as permissionUuid, p.op, po.objecttable as permissionObjectTable, + p.uuid as permissionUuid, p.op, p.opTableName, + po.objecttable as permissionObjectTable, findIdNameByObjectUuid(po.objectTable, po.uuid) as permissionObjectIdName, po.uuid as permissionObjectUuid from queryPermissionsGrantedToSubjectId( targetUserUuid) as p @@ -371,4 +378,15 @@ begin ) xp; -- @formatter:on end; $$; + +create or replace function grantedPermissions(targetUserUuid uuid) + returns table(roleUuid uuid, roleName text, permissionUuid uuid, op RbacOp, opTableName varchar(60), objectTable varchar(60), objectIdName varchar, objectUuid uuid) + returns null on null input + language sql as $$ + select * from grantedPermissionsRaw(targetUserUuid) + union all + select roleUuid, roleName, permissionUuid, 'SELECT'::RbacOp, opTableName, objectTable, objectIdName, objectUuid + from grantedPermissionsRaw(targetUserUuid) + where op <> 'SELECT'::RbacOp; +$$; --// diff --git a/src/main/resources/db/changelog/1-rbac/1056-rbac-trigger-context.sql b/src/main/resources/db/changelog/1-rbac/1056-rbac-trigger-context.sql new file mode 100644 index 00000000..80a92987 --- /dev/null +++ b/src/main/resources/db/changelog/1-rbac/1056-rbac-trigger-context.sql @@ -0,0 +1,61 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset rbac-trigger-context-ENTER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace procedure enterTriggerForObjectUuid(currentObjectUuid uuid) + language plpgsql as $$ +declare + existingObjectUuid text; +begin + existingObjectUuid = current_setting('hsadminng.currentObjectUuid', true); + if (existingObjectUuid > '' ) then + raise exception '[500] currentObjectUuid already defined, already in trigger of "%"', existingObjectUuid; + end if; + execute format('set local hsadminng.currentObjectUuid to %L', currentObjectUuid); +end; $$; + + +-- ============================================================================ +--changeset rbac-trigger-context-CURRENT-ID:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Returns the uuid of the object uuid whose trigger is currently executed as set via `enterTriggerForObjectUuid(...)`. + */ + +create or replace function currentTriggerObjectUuid() + returns uuid + stable -- leakproof + language plpgsql as $$ +declare + currentObjectUuid uuid; +begin + begin + currentObjectUuid = current_setting('hsadminng.currentObjectUuid')::uuid; + return currentObjectUuid; + exception + when others then + return null::uuid; + end; +end; $$; +--// + + +-- ============================================================================ +--changeset rbac-trigger-context-LEAVE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace procedure leaveTriggerForObjectUuid(currentObjectUuid uuid) + language plpgsql as $$ +declare + existingObjectUuid uuid; +begin + existingObjectUuid = current_setting('hsadminng.currentObjectUuid', true); + if ( existingObjectUuid <> currentObjectUuid ) then + raise exception '[500] currentObjectUuid does not match: "%"', existingObjectUuid; + end if; + execute format('reset hsadminng.currentObjectUuid'); +end; $$; + diff --git a/src/main/resources/db/changelog/1-rbac/1057-rbac-role-builder.sql b/src/main/resources/db/changelog/1-rbac/1057-rbac-role-builder.sql new file mode 100644 index 00000000..cb20bbbc --- /dev/null +++ b/src/main/resources/db/changelog/1-rbac/1057-rbac-role-builder.sql @@ -0,0 +1,68 @@ +--liquibase formatted sql + + +-- ================================================================= +-- CREATE ROLE +--changeset rbac-role-builder-create-role:1 endDelimiter:--// +-- ----------------------------------------------------------------- + +-- TODO: rename to defineRoleWithGrants because it does not complain if the role already exists +create or replace function createRoleWithGrants( + roleDescriptor RbacRoleDescriptor, + permissions RbacOp[] = array[]::RbacOp[], + incomingSuperRoles RbacRoleDescriptor[] = array[]::RbacRoleDescriptor[], + outgoingSubRoles RbacRoleDescriptor[] = array[]::RbacRoleDescriptor[], + userUuids uuid[] = array[]::uuid[], + grantedByRole RbacRoleDescriptor = null +) + returns uuid + called on null input + language plpgsql as $$ +declare + roleUuid uuid; + permission RbacOp; + permissionUuid uuid; + subRoleDesc RbacRoleDescriptor; + superRoleDesc RbacRoleDescriptor; + subRoleUuid uuid; + superRoleUuid uuid; + userUuid uuid; + userGrantsByRoleUuid uuid; +begin + roleUuid := coalesce(findRoleId(roleDescriptor), createRole(roleDescriptor)); + + foreach permission in array permissions + loop + permissionUuid := createPermission(roleDescriptor.objectuuid, permission); + call grantPermissionToRole(permissionUuid, roleUuid); + end loop; + + foreach superRoleDesc in array array_remove(incomingSuperRoles, null) + loop + superRoleUuid := getRoleId(superRoleDesc); + call grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed); + end loop; + + foreach subRoleDesc in array array_remove(outgoingSubRoles, null) + loop + subRoleUuid := getRoleId(subRoleDesc); + call grantRoleToRole(subRoleUuid, roleUuid, subRoleDesc.assumed); + end loop; + + if cardinality(userUuids) > 0 then + -- direct grants to users need a grantedByRole which can revoke the grant + if grantedByRole is null then + userGrantsByRoleUuid := roleUuid; -- TODO.impl: or do we want to require an explicit userGrantsByRoleUuid? + else + userGrantsByRoleUuid := getRoleId(grantedByRole); + end if; + foreach userUuid in array userUuids + loop + call grantRoleToUserUnchecked(userGrantsByRoleUuid, roleUuid, userUuid); + end loop; + end if; + + return roleUuid; +end; $$; +--// + diff --git a/src/main/resources/db/changelog/058-rbac-generators.sql b/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql similarity index 55% rename from src/main/resources/db/changelog/058-rbac-generators.sql rename to src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql index fa198308..44281bed 100644 --- a/src/main/resources/db/changelog/058-rbac-generators.sql +++ b/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql @@ -13,8 +13,7 @@ declare begin createInsertTriggerSQL = format($sql$ create trigger createRbacObjectFor_%s_Trigger - before insert - on %s + before insert on %s for each row execute procedure insertRelatedRbacObject(); $sql$, targetTable, targetTable); @@ -36,50 +35,59 @@ end; $$; --changeset rbac-generators-ROLE-DESCRIPTORS:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -create or replace procedure generateRbacRoleDescriptors(prefix text, targetTable text) +create procedure generateRbacRoleDescriptors(prefix text, targetTable text) language plpgsql as $$ declare sql text; begin sql = format($sql$ - create or replace function %1$sOwner(entity %2$s) + create or replace function %1$sOwner(entity %2$s, assumed boolean = true) returns RbacRoleDescriptor language plpgsql strict as $f$ begin - return roleDescriptor('%2$s', entity.uuid, 'owner'); + return roleDescriptor('%2$s', entity.uuid, 'OWNER', assumed); end; $f$; - create or replace function %1$sAdmin(entity %2$s) + create or replace function %1$sAdmin(entity %2$s, assumed boolean = true) returns RbacRoleDescriptor language plpgsql strict as $f$ begin - return roleDescriptor('%2$s', entity.uuid, 'admin'); + return roleDescriptor('%2$s', entity.uuid, 'ADMIN', assumed); end; $f$; - create or replace function %1$sAgent(entity %2$s) + create or replace function %1$sAgent(entity %2$s, assumed boolean = true) returns RbacRoleDescriptor language plpgsql strict as $f$ begin - return roleDescriptor('%2$s', entity.uuid, 'agent'); + return roleDescriptor('%2$s', entity.uuid, 'AGENT', assumed); end; $f$; - create or replace function %1$sTenant(entity %2$s) + create or replace function %1$sTenant(entity %2$s, assumed boolean = true) returns RbacRoleDescriptor language plpgsql strict as $f$ begin - return roleDescriptor('%2$s', entity.uuid, 'tenant'); + return roleDescriptor('%2$s', entity.uuid, 'TENANT', assumed); end; $f$; - create or replace function %1$sGuest(entity %2$s) + -- TODO: remove guest role + create or replace function %1$sGuest(entity %2$s, assumed boolean = true) returns RbacRoleDescriptor language plpgsql strict as $f$ begin - return roleDescriptor('%2$s', entity.uuid, 'guest'); + return roleDescriptor('%2$s', entity.uuid, 'GUEST', assumed); + end; $f$; + + create or replace function %1$sReferrer(entity %2$s) + returns RbacRoleDescriptor + language plpgsql + strict as $f$ + begin + return roleDescriptor('%2$s', entity.uuid, 'REFERRER'); end; $f$; $sql$, prefix, targetTable); @@ -92,7 +100,7 @@ end; $$; --changeset rbac-generators-IDENTITY-VIEW:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -create or replace procedure generateRbacIdentityView(targetTable text, idNameExpression text) +create or replace procedure generateRbacIdentityViewFromQuery(targetTable text, sqlQuery text) language plpgsql as $$ declare sql text; @@ -101,21 +109,22 @@ begin -- create a view to the target main table which maps an idName to the objectUuid sql = format($sql$ - create or replace view %1$s_iv as - select target.uuid, cleanIdentifier(%2$s) as idName - from %1$s as target; + create or replace view %1$s_iv as %2$s; grant all privileges on %1$s_iv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; - $sql$, targetTable, idNameExpression); + $sql$, targetTable, sqlQuery); execute sql; -- creates a function which maps an idName to the objectUuid sql = format($sql$ create or replace function %1$sUuidByIdName(givenIdName varchar) returns uuid - language sql - strict as $f$ - select uuid from %1$s_iv iv where iv.idName = givenIdName; - $f$; + language plpgsql as $f$ + declare + singleMatch uuid; + begin + select uuid into strict singleMatch from %1$s_iv iv where iv.idName = givenIdName; + return singleMatch; + end; $f$; $sql$, targetTable); execute sql; @@ -130,6 +139,20 @@ begin $sql$, targetTable); execute sql; end; $$; + +create or replace procedure generateRbacIdentityViewFromProjection(targetTable text, sqlProjection text) + language plpgsql as $$ +declare + sqlQuery text; +begin + targettable := lower(targettable); + + sqlQuery = format($sql$ + select target.uuid, cleanIdentifier(%2$s) as idName + from %1$s as target; + $sql$, targetTable, sqlProjection); + call generateRbacIdentityViewFromQuery(targetTable, sqlQuery); +end; $$; --// @@ -137,47 +160,80 @@ end; $$; --changeset rbac-generators-RESTRICTED-VIEW:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -create or replace procedure generateRbacRestrictedView(targetTable text, orderBy text, columnUpdates text = null) +create or replace procedure generateRbacRestrictedView(targetTable text, orderBy text, columnUpdates text = null, columnNames text = '*') language plpgsql as $$ declare sql text; + newColumns text; begin targetTable := lower(targetTable); + if columnNames = '*' then + columnNames := columnsNames(targetTable); + end if; /* - Creates a restricted view based on the 'view' permission of the current subject. + Creates a restricted view based on the 'SELECT' permission of the current subject. */ sql := format($sql$ - set session session authorization default; - create view %1$s_rv as - with accessibleObjects as ( - select queryAccessibleObjectUuidsOfSubjectIds('view', '%1$s', currentSubjectsUuids()) + create or replace view %1$s_rv as + with accessible_%1$s_uuids as ( + with recursive + recursive_grants as + (select distinct rbacgrants.descendantuuid, + rbacgrants.ascendantuuid, + 1 as level, + true + from rbacgrants + where rbacgrants.assumed + and (rbacgrants.ascendantuuid = any (currentsubjectsuuids())) + union all + select distinct g.descendantuuid, + g.ascendantuuid, + grants.level + 1 as level, + assertTrue(grants.level < 22, 'too many grant-levels: ' || grants.level) + from rbacgrants g + join recursive_grants grants on grants.descendantuuid = g.ascendantuuid + where g.assumed), + grant_count AS ( + SELECT COUNT(*) AS grant_count FROM recursive_grants + ), + count_check as (select assertTrue((select count(*) as grant_count from recursive_grants) < 400000, + 'too many grants for current subjects: ' || (select count(*) as grant_count from recursive_grants)) + as valid) + select distinct perm.objectuuid + from recursive_grants + join rbacpermission perm on recursive_grants.descendantuuid = perm.uuid + join rbacobject obj on obj.uuid = perm.objectuuid + join count_check cc on cc.valid + where obj.objectTable = '%1$s' -- 'SELECT' permission is included in all other permissions ) select target.* from %1$s as target - where target.uuid in (select * from accessibleObjects) + where target.uuid in (select * from accessible_%1$s_uuids) order by %2$s; - grant all privileges on %1$s_rv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; + + grant all privileges on %1$s_rv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; $sql$, targetTable, orderBy); execute sql; /** Instead of insert trigger function for the restricted view. */ + newColumns := 'new.' || replace(columnNames, ',', ', new.'); sql := format($sql$ - create or replace function %1$sInsert() - returns trigger - language plpgsql as $f$ - declare - newTargetRow %1$s; - begin - insert - into %1$s - values (new.*) - returning * into newTargetRow; - return newTargetRow; - end; $f$; - $sql$, targetTable); + create or replace function %1$sInsert() + returns trigger + language plpgsql as $f$ + declare + newTargetRow %1$s; + begin + insert + into %1$s (%2$s) + values (%3$s) + returning * into newTargetRow; + return newTargetRow; + end; $f$; + $sql$, targetTable, columnNames, newColumns); execute sql; /* @@ -200,7 +256,7 @@ begin returns trigger language plpgsql as $f$ begin - if old.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('delete', '%1$s', currentSubjectsUuids())) then + if old.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('DELETE', '%1$s', currentSubjectsUuids())) then delete from %1$s p where p.uuid = old.uuid; return old; end if; @@ -223,7 +279,7 @@ begin /** Instead of update trigger function for the restricted view - based on the 'edit' permission of the current subject. + based on the 'UPDATE' permission of the current subject. */ if columnUpdates is not null then sql := format($sql$ @@ -231,7 +287,7 @@ begin returns trigger language plpgsql as $f$ begin - if old.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('edit', '%1$s', currentSubjectsUuids())) then + if old.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('UPDATE', '%1$s', currentSubjectsUuids())) then update %1$s set %2$s where uuid = old.uuid; diff --git a/src/main/resources/db/changelog/059-rbac-statistics.sql b/src/main/resources/db/changelog/1-rbac/1059-rbac-statistics.sql similarity index 100% rename from src/main/resources/db/changelog/059-rbac-statistics.sql rename to src/main/resources/db/changelog/1-rbac/1059-rbac-statistics.sql diff --git a/src/main/resources/db/changelog/080-rbac-global.sql b/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql similarity index 80% rename from src/main/resources/db/changelog/080-rbac-global.sql rename to src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql index 034400fa..c28a464d 100644 --- a/src/main/resources/db/changelog/080-rbac-global.sql +++ b/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql @@ -22,6 +22,19 @@ grant select on global to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; --// +-- ============================================================================ +--changeset rbac-global-IS-GLOBAL-ADMIN:1 endDelimiter:--// +-- ------------------------------------------------------------------ + +create or replace function isGlobalAdmin() + returns boolean + language plpgsql as $$ +begin + return isGranted(currentSubjectsUuids(), findRoleId(globalAdmin())); +end; $$; +--// + + -- ============================================================================ --changeset rbac-global-HAS-GLOBAL-PERMISSION:1 endDelimiter:--// -- ------------------------------------------------------------------ @@ -96,18 +109,41 @@ commit; /* A global administrator role. */ -create or replace function globalAdmin() +create or replace function globalAdmin(assumed boolean = true) returns RbacRoleDescriptor returns null on null input stable -- leakproof language sql as $$ -select 'global', (select uuid from RbacObject where objectTable = 'global'), 'admin'::RbacRoleType; +select 'global', (select uuid from RbacObject where objectTable = 'global'), 'ADMIN'::RbacRoleType, assumed; $$; begin transaction; -call defineContext('creating global admin role', null, null, null); -select createRole(globalAdmin()); + call defineContext('creating role:global#global:ADMIN', null, null, null); + select createRole(globalAdmin()); commit; +--// + + +-- ============================================================================ +--changeset rbac-global-GUEST-ROLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + A global guest role. + */ +create or replace function globalGuest(assumed boolean = true) + returns RbacRoleDescriptor + returns null on null input + stable -- leakproof + language sql as $$ +select 'global', (select uuid from RbacObject where objectTable = 'global'), 'GUEST'::RbacRoleType, assumed; +$$; + +begin transaction; + call defineContext('creating role:global#global:guest', null, null, null); + select createRole(globalGuest()); +commit; +--// + -- ============================================================================ --changeset rbac-global-ADMIN-USERS:1 context:dev,tc endDelimiter:--// diff --git a/src/main/resources/db/changelog/113-test-customer-rbac.sql b/src/main/resources/db/changelog/113-test-customer-rbac.sql deleted file mode 100644 index 1f563aa2..00000000 --- a/src/main/resources/db/changelog/113-test-customer-rbac.sql +++ /dev/null @@ -1,142 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset test-customer-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('test_customer'); ---// - - --- ============================================================================ ---changeset test-customer-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('testCustomer', 'test_customer'); ---// - - --- ============================================================================ ---changeset test-customer-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates the roles and their assignments for a new customer for the AFTER INSERT TRIGGER. - */ - -create or replace function createRbacRolesForTestCustomer() - returns trigger - language plpgsql - strict as $$ -declare - testCustomerOwnerUuid uuid; - customerAdminUuid uuid; -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - -- the owner role with full access for Hostsharing administrators - testCustomerOwnerUuid = createRoleWithGrants( - testCustomerOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()] - ); - - -- the admin role for the customer's admins, who can view and add products - customerAdminUuid = createRoleWithGrants( - testCustomerAdmin(NEW), - permissions => array['view', 'add-package'], - -- NO auto assume for customer owner to avoid exploding permissions for administrators - userUuids => array[getRbacUserId(NEW.adminUserName, 'create')], -- implicitly ignored if null - grantedByRole => globalAdmin() - ); - - -- allow the customer owner role (thus administrators) to assume the customer admin role - call grantRoleToRole(customerAdminUuid, testCustomerOwnerUuid, false); - - -- the tenant role which later can be used by owners+admins of sub-objects - perform createRoleWithGrants( - testCustomerTenant(NEW), - permissions => array['view'] - ); - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ - -drop trigger if exists createRbacRolesForTestCustomer_Trigger on test_customer; -create trigger createRbacRolesForTestCustomer_Trigger - after insert - on test_customer - for each row -execute procedure createRbacRolesForTestCustomer(); ---// - - --- ============================================================================ ---changeset test-customer-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('test_customer', $idName$ - target.prefix - $idName$); ---// - - --- ============================================================================ ---changeset test-customer-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('test_customer', 'target.prefix', - $updates$ - reference = new.reference, - prefix = new.prefix, - adminUserName = new.adminUserName - $updates$); ---// - - --- ============================================================================ ---changeset test-customer-rbac-ADD-CUSTOMER:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for add-customer and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global add-customer permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['add-customer']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addTestCustomerNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] add-customer not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to add a new customer. - */ -create trigger test_customer_insert_trigger - before insert - on test_customer - for each row - when ( not hasGlobalPermission('add-customer') ) -execute procedure addTestCustomerNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/123-test-package-rbac.sql b/src/main/resources/db/changelog/123-test-package-rbac.sql deleted file mode 100644 index 8a2fd857..00000000 --- a/src/main/resources/db/changelog/123-test-package-rbac.sql +++ /dev/null @@ -1,108 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset test-package-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('test_package'); ---// - - --- ============================================================================ ---changeset test-package-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('testPackage', 'test_package'); ---// - - --- ============================================================================ ---changeset test-package-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates the roles and their assignments for a new package for the AFTER INSERT TRIGGER. - */ -create or replace function createRbacRolesForTestPackage() - returns trigger - language plpgsql - strict as $$ -declare - parentCustomer test_customer; - packageOwnerRoleUuid uuid; - packageAdminRoleUuid uuid; -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - select * from test_customer as c where c.uuid = NEW.customerUuid into parentCustomer; - - -- an owner role is created and assigned to the customer's admin role - perform createRoleWithGrants( - testPackageOwner(NEW), - permissions => array ['*'], - incomingSuperRoles => array[testCustomerAdmin(parentCustomer)] - ); - - -- an owner role is created and assigned to the package owner role - perform createRoleWithGrants( - testPackageAdmin(NEW), - permissions => array ['add-domain'], - incomingSuperRoles => array[testPackageOwner(NEW)] - ); - - -- and a package tenant role is created and assigned to the package admin as well - perform createRoleWithGrants( - testPackageTenant(NEW), - permissions => array['view'], - incomingsuperroles => array[testPackageAdmin(NEW)], - outgoingSubRoles => array[testCustomerTenant(parentCustomer)] - ); - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new package. - */ - -create trigger createRbacRolesForTestPackage_Trigger - after insert - on test_package - for each row -execute procedure createRbacRolesForTestPackage(); ---// - - --- ============================================================================ ---changeset test-package-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('test_package', 'target.name'); ---// - - --- ============================================================================ ---changeset test-package-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a view to the customer main table which maps the identifying name - (in this case, the prefix) to the objectUuid. - */ --- drop view if exists test_package_rv; --- create or replace view test_package_rv as --- select target.* --- from test_package as target --- where target.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('view', 'test_package', currentSubjectsUuids())) --- order by target.name; --- grant all privileges on test_package_rv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; - -call generateRbacRestrictedView('test_package', 'target.name', - $updates$ - version = new.version, - customerUuid = new.customerUuid, - name = new.name, - description = new.description - $updates$); - ---// - - diff --git a/src/main/resources/db/changelog/133-test-domain-rbac.sql b/src/main/resources/db/changelog/133-test-domain-rbac.sql deleted file mode 100644 index 89b63018..00000000 --- a/src/main/resources/db/changelog/133-test-domain-rbac.sql +++ /dev/null @@ -1,114 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset test-domain-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('test_domain'); ---// - - --- ============================================================================ ---changeset test-domain-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('testDomain', 'test_domain'); - -create or replace function createTestDomainTenantRoleIfNotExists(domain test_domain) - returns uuid - returns null on null input - language plpgsql as $$ -declare - domainTenantRoleDesc RbacRoleDescriptor; - domainTenantRoleUuid uuid; -begin - domainTenantRoleDesc = testdomainTenant(domain); - domainTenantRoleUuid = findRoleId(domainTenantRoleDesc); - if domainTenantRoleUuid is not null then - return domainTenantRoleUuid; - end if; - - return createRoleWithGrants( - domainTenantRoleDesc, - permissions => array['view'], - incomingSuperRoles => array[testdomainAdmin(domain)] - ); -end; $$; ---// - - --- ============================================================================ ---changeset test-domain-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates the roles and their assignments for a new domain for the AFTER INSERT TRIGGER. - */ - -create or replace function createRbacRulesForTestDomain() - returns trigger - language plpgsql - strict as $$ -declare - parentPackage test_package; -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - select * from test_package where uuid = NEW.packageUuid into parentPackage; - - -- an owner role is created and assigned to the package's admin group - perform createRoleWithGrants( - testDomainOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[testPackageAdmin(parentPackage)] - ); - - -- and a domain admin role is created and assigned to the domain owner as well - perform createRoleWithGrants( - testDomainAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[testDomainOwner(NEW)], - outgoingSubRoles => array[testPackageTenant(parentPackage)] - ); - - -- a tenent role is only created on demand - - return NEW; -end; $$; - - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new domain. - */ -drop trigger if exists createRbacRulesForTestDomain_Trigger on test_domain; -create trigger createRbacRulesForTestDomain_Trigger - after insert - on test_domain - for each row -execute procedure createRbacRulesForTestDomain(); ---// - - --- ============================================================================ ---changeset test-domain-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('test_domain', $idName$ - target.name - $idName$); ---// - - --- ============================================================================ ---changeset test-domain-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a view to the customer main table which maps the identifying name - (in this case, the prefix) to the objectUuid. - */ -drop view if exists test_domain_rv; -create or replace view test_domain_rv as -select target.* - from test_domain as target - where target.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('view', 'domain', currentSubjectsUuids())); -grant all privileges on test_domain_rv to ${HSADMINNG_POSTGRES_RESTRICTED_USERNAME}; ---// diff --git a/src/main/resources/db/changelog/110-test-customer.sql b/src/main/resources/db/changelog/2-test/201-test-customer/2010-test-customer.sql similarity index 92% rename from src/main/resources/db/changelog/110-test-customer.sql rename to src/main/resources/db/changelog/2-test/201-test-customer/2010-test-customer.sql index 7eb539f7..559ba51a 100644 --- a/src/main/resources/db/changelog/110-test-customer.sql +++ b/src/main/resources/db/changelog/2-test/201-test-customer/2010-test-customer.sql @@ -7,6 +7,7 @@ create table if not exists test_customer ( uuid uuid unique references RbacObject (uuid), + version int not null default 0, reference int not null unique check (reference between 10000 and 99999), prefix character(3) unique, adminUserName varchar(63) diff --git a/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.md b/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.md new file mode 100644 index 00000000..19e67a38 --- /dev/null +++ b/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.md @@ -0,0 +1,45 @@ +### rbac customer + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph customer["`**customer**`"] + direction TB + style customer fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph customer:roles[ ] + style customer:roles fill:#dd4901,stroke:white + + role:customer:OWNER[[customer:OWNER]] + role:customer:ADMIN[[customer:ADMIN]] + role:customer:TENANT[[customer:TENANT]] + end + + subgraph customer:permissions[ ] + style customer:permissions fill:#dd4901,stroke:white + + perm:customer:INSERT{{customer:INSERT}} + perm:customer:DELETE{{customer:DELETE}} + perm:customer:UPDATE{{customer:UPDATE}} + perm:customer:SELECT{{customer:SELECT}} + end +end + +%% granting roles to users +user:creator ==>|XX| role:customer:OWNER + +%% granting roles to roles +role:global:ADMIN ==>|XX| role:customer:OWNER +role:customer:OWNER ==> role:customer:ADMIN +role:customer:ADMIN ==> role:customer:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:customer:INSERT +role:customer:OWNER ==> perm:customer:DELETE +role:customer:ADMIN ==> perm:customer:UPDATE +role:customer:TENANT ==> perm:customer:SELECT + +``` diff --git a/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.sql b/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.sql new file mode 100644 index 00000000..e1540c9a --- /dev/null +++ b/src/main/resources/db/changelog/2-test/201-test-customer/2013-test-customer-rbac.sql @@ -0,0 +1,180 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset test-customer-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('test_customer'); +--// + + +-- ============================================================================ +--changeset test-customer-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('testCustomer', 'test_customer'); +--// + + +-- ============================================================================ +--changeset test-customer-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForTestCustomer( + NEW test_customer +) + language plpgsql as $$ + +declare + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + perform createRoleWithGrants( + testCustomerOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN(unassumed())], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + testCustomerADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[testCustomerOWNER(NEW)] + ); + + perform createRoleWithGrants( + testCustomerTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[testCustomerADMIN(NEW)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new test_customer row. + */ + +create or replace function insertTriggerForTestCustomer_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForTestCustomer(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForTestCustomer_tg + after insert on test_customer + for each row +execute procedure insertTriggerForTestCustomer_tf(); +--// + + +-- ============================================================================ +--changeset test-customer-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO test_customer permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO test_customer permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'test_customer'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants test_customer INSERT permission to specified role of new global rows. +*/ +create or replace function new_test_customer_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'test_customer'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_test_customer_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_test_customer_grants_insert_to_global_tf(); + + +-- ============================================================================ +--changeset test_customer-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to test_customer. +*/ +create or replace function test_customer_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + + raise exception '[403] insert into test_customer values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger test_customer_insert_permission_check_tg + before insert on test_customer + for each row + execute procedure test_customer_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset test-customer-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('test_customer', + $idName$ + prefix + $idName$); +--// + + +-- ============================================================================ +--changeset test-customer-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('test_customer', + $orderBy$ + reference + $orderBy$, + $updates$ + reference = new.reference, + prefix = new.prefix, + adminUserName = new.adminUserName + $updates$); +--// + diff --git a/src/main/resources/db/changelog/118-test-customer-test-data.sql b/src/main/resources/db/changelog/2-test/201-test-customer/2018-test-customer-test-data.sql similarity index 80% rename from src/main/resources/db/changelog/118-test-customer-test-data.sql rename to src/main/resources/db/changelog/2-test/201-test-customer/2018-test-customer-test-data.sql index 353b8f59..f05cbafb 100644 --- a/src/main/resources/db/changelog/118-test-customer-test-data.sql +++ b/src/main/resources/db/changelog/2-test/201-test-customer/2018-test-customer-test-data.sql @@ -25,20 +25,26 @@ create or replace procedure createTestCustomerTestData( ) language plpgsql as $$ declare - currentTask varchar; custRowId uuid; custAdminName varchar; + custAdminUuid uuid; + newCust test_customer; begin - currentTask = 'creating RBAC test customer #' || custReference || '/' || custPrefix; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - custRowId = uuid_generate_v4(); custAdminName = 'customer-admin@' || custPrefix || '.example.com'; + custAdminUuid = createRbacUser(custAdminName); insert into test_customer (reference, prefix, adminUserName) values (custReference, custPrefix, custAdminName); + + select * into newCust + from test_customer where reference=custReference; + call grantRoleToUser( + getRoleId(testCustomerOwner(newCust)), + getRoleId(testCustomerAdmin(newCust)), + custAdminUuid, + true); end; $$; --// @@ -66,6 +72,8 @@ end; $$; do language plpgsql $$ begin + call defineContext('creating RBAC test customer', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + call createTestCustomerTestData(99901, 'xxx'); call createTestCustomerTestData(99902, 'yyy'); call createTestCustomerTestData(99903, 'zzz'); diff --git a/src/main/resources/db/changelog/120-test-package.sql b/src/main/resources/db/changelog/2-test/202-test-package/2020-test-package.sql similarity index 100% rename from src/main/resources/db/changelog/120-test-package.sql rename to src/main/resources/db/changelog/2-test/202-test-package/2020-test-package.sql diff --git a/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.md b/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.md new file mode 100644 index 00000000..af3a5f84 --- /dev/null +++ b/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.md @@ -0,0 +1,59 @@ +### rbac package + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph customer["`**customer**`"] + direction TB + style customer fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph customer:roles[ ] + style customer:roles fill:#99bcdb,stroke:white + + role:customer:OWNER[[customer:OWNER]] + role:customer:ADMIN[[customer:ADMIN]] + role:customer:TENANT[[customer:TENANT]] + end +end + +subgraph package["`**package**`"] + direction TB + style package fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph package:roles[ ] + style package:roles fill:#dd4901,stroke:white + + role:package:OWNER[[package:OWNER]] + role:package:ADMIN[[package:ADMIN]] + role:package:TENANT[[package:TENANT]] + end + + subgraph package:permissions[ ] + style package:permissions fill:#dd4901,stroke:white + + perm:package:INSERT{{package:INSERT}} + perm:package:DELETE{{package:DELETE}} + perm:package:UPDATE{{package:UPDATE}} + perm:package:SELECT{{package:SELECT}} + end +end + +%% granting roles to roles +role:global:ADMIN -.->|XX| role:customer:OWNER +role:customer:OWNER -.-> role:customer:ADMIN +role:customer:ADMIN -.-> role:customer:TENANT +role:customer:ADMIN ==> role:package:OWNER +role:package:OWNER ==> role:package:ADMIN +role:package:ADMIN ==> role:package:TENANT +role:package:TENANT ==> role:customer:TENANT + +%% granting permissions to roles +role:customer:ADMIN ==> perm:package:INSERT +role:package:OWNER ==> perm:package:DELETE +role:package:OWNER ==> perm:package:UPDATE +role:package:TENANT ==> perm:package:SELECT + +``` diff --git a/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.sql b/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.sql new file mode 100644 index 00000000..9ec9c06a --- /dev/null +++ b/src/main/resources/db/changelog/2-test/202-test-package/2023-test-package-rbac.sql @@ -0,0 +1,245 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset test-package-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('test_package'); +--// + + +-- ============================================================================ +--changeset test-package-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('testPackage', 'test_package'); +--// + + +-- ============================================================================ +--changeset test-package-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForTestPackage( + NEW test_package +) + language plpgsql as $$ + +declare + newCustomer test_customer; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM test_customer WHERE uuid = NEW.customerUuid INTO newCustomer; + assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s', NEW.customerUuid); + + + perform createRoleWithGrants( + testPackageOWNER(NEW), + permissions => array['DELETE', 'UPDATE'], + incomingSuperRoles => array[testCustomerADMIN(newCustomer)] + ); + + perform createRoleWithGrants( + testPackageADMIN(NEW), + incomingSuperRoles => array[testPackageOWNER(NEW)] + ); + + perform createRoleWithGrants( + testPackageTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[testPackageADMIN(NEW)], + outgoingSubRoles => array[testCustomerTENANT(newCustomer)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new test_package row. + */ + +create or replace function insertTriggerForTestPackage_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForTestPackage(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForTestPackage_tg + after insert on test_package + for each row +execute procedure insertTriggerForTestPackage_tf(); +--// + + +-- ============================================================================ +--changeset test-package-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForTestPackage( + OLD test_package, + NEW test_package +) + language plpgsql as $$ + +declare + oldCustomer test_customer; + newCustomer test_customer; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM test_customer WHERE uuid = OLD.customerUuid INTO oldCustomer; + assert oldCustomer.uuid is not null, format('oldCustomer must not be null for OLD.customerUuid = %s', OLD.customerUuid); + + SELECT * FROM test_customer WHERE uuid = NEW.customerUuid INTO newCustomer; + assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s', NEW.customerUuid); + + + if NEW.customerUuid <> OLD.customerUuid then + + call revokeRoleFromRole(testPackageOWNER(OLD), testCustomerADMIN(oldCustomer)); + call grantRoleToRole(testPackageOWNER(NEW), testCustomerADMIN(newCustomer)); + + call revokeRoleFromRole(testCustomerTENANT(oldCustomer), testPackageTENANT(OLD)); + call grantRoleToRole(testCustomerTENANT(newCustomer), testPackageTENANT(NEW)); + + end if; + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new test_package row. + */ + +create or replace function updateTriggerForTestPackage_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForTestPackage(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForTestPackage_tg + after update on test_package + for each row +execute procedure updateTriggerForTestPackage_tf(); +--// + + +-- ============================================================================ +--changeset test-package-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to test_customer ---------------------------- + +/* + Grants INSERT INTO test_package permissions to specified role of pre-existing test_customer rows. + */ +do language plpgsql $$ + declare + row test_customer; + begin + call defineContext('create INSERT INTO test_package permissions for pre-exising test_customer rows'); + + FOR row IN SELECT * FROM test_customer + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'test_package'), + testCustomerADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants test_package INSERT permission to specified role of new test_customer rows. +*/ +create or replace function new_test_package_grants_insert_to_test_customer_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'test_package'), + testCustomerADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_test_package_grants_insert_to_test_customer_tg + after insert on test_customer + for each row +execute procedure new_test_package_grants_insert_to_test_customer_tf(); + + +-- ============================================================================ +--changeset test_package-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to test_package. +*/ +create or replace function test_package_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via direct foreign key: NEW.customerUuid + if hasInsertPermission(NEW.customerUuid, 'test_package') then + return NEW; + end if; + + raise exception '[403] insert into test_package values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger test_package_insert_permission_check_tg + before insert on test_package + for each row + execute procedure test_package_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset test-package-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('test_package', + $idName$ + name + $idName$); +--// + + +-- ============================================================================ +--changeset test-package-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('test_package', + $orderBy$ + name + $orderBy$, + $updates$ + version = new.version, + customerUuid = new.customerUuid, + description = new.description + $updates$); +--// + diff --git a/src/main/resources/db/changelog/128-test-package-test-data.sql b/src/main/resources/db/changelog/2-test/202-test-package/2028-test-package-test-data.sql similarity index 84% rename from src/main/resources/db/changelog/128-test-package-test-data.sql rename to src/main/resources/db/changelog/2-test/202-test-package/2028-test-package-test-data.sql index 4667b742..bf4a9f3b 100644 --- a/src/main/resources/db/changelog/128-test-package-test-data.sql +++ b/src/main/resources/db/changelog/2-test/202-test-package/2028-test-package-test-data.sql @@ -13,7 +13,6 @@ declare custAdminUser varchar; custAdminRole varchar; pacName varchar; - currentTask varchar; pac test_package; begin select * from test_customer where test_customer.prefix = customerPrefix into cust; @@ -21,13 +20,9 @@ begin for t in 0..(pacCount-1) loop pacName = cust.prefix || to_char(t, 'fm00'); - currentTask = 'creating RBAC test package #' || pacName || ' for customer ' || cust.prefix || ' #' || - cust.uuid; - custAdminUser = 'customer-admin@' || cust.prefix || '.example.com'; - custAdminRole = 'test_customer#' || cust.prefix || '.admin'; - call defineContext(currentTask, null, custAdminUser, custAdminRole); - raise notice 'task: % by % as %', currentTask, custAdminUser, custAdminRole; + custAdminRole = 'test_customer#' || cust.prefix || ':ADMIN'; + call defineContext('creating RBAC test package', null, 'superuser-fran@hostsharing.net', custAdminRole); insert into test_package (customerUuid, name, description) @@ -35,7 +30,7 @@ begin returning * into pac; call grantRoleToUser( - getRoleId(testCustomerAdmin(cust), 'fail'), + getRoleId(testCustomerAdmin(cust)), findRoleId(testPackageAdmin(pac)), createRbacUser('pac-admin-' || pacName || '@' || cust.prefix || '.example.com'), true); diff --git a/src/main/resources/db/changelog/130-test-domain.sql b/src/main/resources/db/changelog/2-test/203-test-domain/2030-test-domain.sql similarity index 100% rename from src/main/resources/db/changelog/130-test-domain.sql rename to src/main/resources/db/changelog/2-test/203-test-domain/2030-test-domain.sql diff --git a/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.md b/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.md new file mode 100644 index 00000000..72693972 --- /dev/null +++ b/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.md @@ -0,0 +1,75 @@ +### rbac domain + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph domain["`**domain**`"] + direction TB + style domain fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph domain:roles[ ] + style domain:roles fill:#dd4901,stroke:white + + role:domain:OWNER[[domain:OWNER]] + role:domain:ADMIN[[domain:ADMIN]] + end + + subgraph domain:permissions[ ] + style domain:permissions fill:#dd4901,stroke:white + + perm:domain:INSERT{{domain:INSERT}} + perm:domain:DELETE{{domain:DELETE}} + perm:domain:UPDATE{{domain:UPDATE}} + perm:domain:SELECT{{domain:SELECT}} + end +end + +subgraph package["`**package**`"] + direction TB + style package fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph package:roles[ ] + style package:roles fill:#99bcdb,stroke:white + + role:package:OWNER[[package:OWNER]] + role:package:ADMIN[[package:ADMIN]] + role:package:TENANT[[package:TENANT]] + end +end + +subgraph package.customer["`**package.customer**`"] + direction TB + style package.customer fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph package.customer:roles[ ] + style package.customer:roles fill:#99bcdb,stroke:white + + role:package.customer:OWNER[[package.customer:OWNER]] + role:package.customer:ADMIN[[package.customer:ADMIN]] + role:package.customer:TENANT[[package.customer:TENANT]] + end +end + +%% granting roles to roles +role:global:ADMIN -.->|XX| role:package.customer:OWNER +role:package.customer:OWNER -.-> role:package.customer:ADMIN +role:package.customer:ADMIN -.-> role:package.customer:TENANT +role:package.customer:ADMIN -.-> role:package:OWNER +role:package:OWNER -.-> role:package:ADMIN +role:package:ADMIN -.-> role:package:TENANT +role:package:TENANT -.-> role:package.customer:TENANT +role:package:ADMIN ==> role:domain:OWNER +role:domain:OWNER ==> role:package:TENANT +role:domain:OWNER ==> role:domain:ADMIN +role:domain:ADMIN ==> role:package:TENANT + +%% granting permissions to roles +role:package:ADMIN ==> perm:domain:INSERT +role:domain:OWNER ==> perm:domain:DELETE +role:domain:OWNER ==> perm:domain:UPDATE +role:domain:ADMIN ==> perm:domain:SELECT + +``` diff --git a/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.sql b/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.sql new file mode 100644 index 00000000..042021c9 --- /dev/null +++ b/src/main/resources/db/changelog/2-test/203-test-domain/2033-test-domain-rbac.sql @@ -0,0 +1,244 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset test-domain-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('test_domain'); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('testDomain', 'test_domain'); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForTestDomain( + NEW test_domain +) + language plpgsql as $$ + +declare + newPackage test_package; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM test_package WHERE uuid = NEW.packageUuid INTO newPackage; + assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s', NEW.packageUuid); + + + perform createRoleWithGrants( + testDomainOWNER(NEW), + permissions => array['DELETE', 'UPDATE'], + incomingSuperRoles => array[testPackageADMIN(newPackage)], + outgoingSubRoles => array[testPackageTENANT(newPackage)] + ); + + perform createRoleWithGrants( + testDomainADMIN(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[testDomainOWNER(NEW)], + outgoingSubRoles => array[testPackageTENANT(newPackage)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new test_domain row. + */ + +create or replace function insertTriggerForTestDomain_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForTestDomain(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForTestDomain_tg + after insert on test_domain + for each row +execute procedure insertTriggerForTestDomain_tf(); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForTestDomain( + OLD test_domain, + NEW test_domain +) + language plpgsql as $$ + +declare + oldPackage test_package; + newPackage test_package; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM test_package WHERE uuid = OLD.packageUuid INTO oldPackage; + assert oldPackage.uuid is not null, format('oldPackage must not be null for OLD.packageUuid = %s', OLD.packageUuid); + + SELECT * FROM test_package WHERE uuid = NEW.packageUuid INTO newPackage; + assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s', NEW.packageUuid); + + + if NEW.packageUuid <> OLD.packageUuid then + + call revokeRoleFromRole(testDomainOWNER(OLD), testPackageADMIN(oldPackage)); + call grantRoleToRole(testDomainOWNER(NEW), testPackageADMIN(newPackage)); + + call revokeRoleFromRole(testPackageTENANT(oldPackage), testDomainOWNER(OLD)); + call grantRoleToRole(testPackageTENANT(newPackage), testDomainOWNER(NEW)); + + call revokeRoleFromRole(testPackageTENANT(oldPackage), testDomainADMIN(OLD)); + call grantRoleToRole(testPackageTENANT(newPackage), testDomainADMIN(NEW)); + + end if; + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new test_domain row. + */ + +create or replace function updateTriggerForTestDomain_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForTestDomain(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForTestDomain_tg + after update on test_domain + for each row +execute procedure updateTriggerForTestDomain_tf(); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to test_package ---------------------------- + +/* + Grants INSERT INTO test_domain permissions to specified role of pre-existing test_package rows. + */ +do language plpgsql $$ + declare + row test_package; + begin + call defineContext('create INSERT INTO test_domain permissions for pre-exising test_package rows'); + + FOR row IN SELECT * FROM test_package + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'test_domain'), + testPackageADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants test_domain INSERT permission to specified role of new test_package rows. +*/ +create or replace function new_test_domain_grants_insert_to_test_package_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'test_domain'), + testPackageADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_test_domain_grants_insert_to_test_package_tg + after insert on test_package + for each row +execute procedure new_test_domain_grants_insert_to_test_package_tf(); + + +-- ============================================================================ +--changeset test_domain-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to test_domain. +*/ +create or replace function test_domain_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via direct foreign key: NEW.packageUuid + if hasInsertPermission(NEW.packageUuid, 'test_domain') then + return NEW; + end if; + + raise exception '[403] insert into test_domain values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger test_domain_insert_permission_check_tg + before insert on test_domain + for each row + execute procedure test_domain_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('test_domain', + $idName$ + name + $idName$); +--// + + +-- ============================================================================ +--changeset test-domain-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('test_domain', + $orderBy$ + name + $orderBy$, + $updates$ + version = new.version, + packageUuid = new.packageUuid, + description = new.description + $updates$); +--// + diff --git a/src/main/resources/db/changelog/138-test-domain-test-data.sql b/src/main/resources/db/changelog/2-test/203-test-domain/2038-test-domain-test-data.sql similarity index 90% rename from src/main/resources/db/changelog/138-test-domain-test-data.sql rename to src/main/resources/db/changelog/2-test/203-test-domain/2038-test-domain-test-data.sql index 47326f49..e2aa870f 100644 --- a/src/main/resources/db/changelog/138-test-domain-test-data.sql +++ b/src/main/resources/db/changelog/2-test/203-test-domain/2038-test-domain-test-data.sql @@ -11,7 +11,6 @@ create or replace procedure createdomainTestData( packageName varchar, domainCou declare pac record; pacAdmin varchar; - currentTask varchar; begin select p.uuid, p.name, c.prefix as custPrefix from test_package p @@ -21,10 +20,8 @@ begin for t in 0..(domainCount-1) loop - currentTask = 'creating RBAC test domain #' || t || ' for package ' || pac.name || ' #' || pac.uuid; - raise notice 'task: %', currentTask; pacAdmin = 'pac-admin-' || pac.name || '@' || pac.custPrefix || '.example.com'; - call defineContext(currentTask, null, pacAdmin, null); + call defineContext('creating RBAC test domain', null, pacAdmin, null); insert into test_domain (name, packageUuid) diff --git a/src/main/resources/db/changelog/203-hs-office-contact-rbac.sql b/src/main/resources/db/changelog/203-hs-office-contact-rbac.sql deleted file mode 100644 index 7ba7891b..00000000 --- a/src/main/resources/db/changelog/203-hs-office-contact-rbac.sql +++ /dev/null @@ -1,141 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-contact-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_contact'); ---// - - --- ============================================================================ ---changeset hs-office-contact-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeContact', 'hs_office_contact'); ---// - - --- ============================================================================ ---changeset hs-office-contact-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates the roles and their assignments for a new contact for the AFTER INSERT TRIGGER. - */ - -create or replace function createRbacRolesForHsOfficeContact() - returns trigger - language plpgsql - strict as $$ -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - perform createRoleWithGrants( - hsOfficeContactOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()], - userUuids => array[currentUserUuid()], - grantedByRole => globalAdmin() - ); - - perform createRoleWithGrants( - hsOfficeContactAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficeContactOwner(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeContactTenant(NEW), - incomingSuperRoles => array[hsOfficeContactAdmin(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeContactGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficeContactTenant(NEW)] - ); - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ - -create trigger createRbacRolesForHsOfficeContact_Trigger - after insert - on hs_office_contact - for each row -execute procedure createRbacRolesForHsOfficeContact(); ---// - - --- ============================================================================ ---changeset hs-office-contact-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -call generateRbacIdentityView('hs_office_contact', $idName$ - target.label - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-contact-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_contact', 'target.label', - $updates$ - label = new.label, - postalAddress = new.postalAddress, - emailAddresses = new.emailAddresses, - phoneNumbers = new.phoneNumbers - $updates$); ---/ - - --- ============================================================================ ---changeset hs-office-contact-rbac-NEW-CONTACT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-contact and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid; - begin - call defineContext('granting global new-contact permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-contact']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeContactNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-contact not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_contact_insert_trigger - before insert - on hs_office_contact - for each row - -- TODO.spec: who is allowed to create new contacts - when ( not hasAssumedRole() ) -execute procedure addHsOfficeContactNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/213-hs-office-person-rbac.sql b/src/main/resources/db/changelog/213-hs-office-person-rbac.sql deleted file mode 100644 index 42eacf2f..00000000 --- a/src/main/resources/db/changelog/213-hs-office-person-rbac.sql +++ /dev/null @@ -1,139 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-person-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_person'); ---// - - --- ============================================================================ ---changeset hs-office-person-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficePerson', 'hs_office_person'); ---// - - --- ============================================================================ ---changeset hs-office-person-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates the roles and their assignments for a new person for the AFTER INSERT TRIGGER. - */ -create or replace function createRbacRolesForHsOfficePerson() - returns trigger - language plpgsql - strict as $$ -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - perform createRoleWithGrants( - hsOfficePersonOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()], - userUuids => array[currentUserUuid()], - grantedByRole => globalAdmin() - ); - - -- TODO: who is admin? the person itself? is it allowed for the person itself or a representative to edit the data? - perform createRoleWithGrants( - hsOfficePersonAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficePersonOwner(NEW)] - ); - - perform createRoleWithGrants( - hsOfficePersonTenant(NEW), - incomingSuperRoles => array[hsOfficePersonAdmin(NEW)] - ); - - perform createRoleWithGrants( - hsOfficePersonGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficePersonTenant(NEW)] - ); - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ - -create trigger createRbacRolesForHsOfficePerson_Trigger - after insert - on hs_office_person - for each row -execute procedure createRbacRolesForHsOfficePerson(); ---// - - --- ============================================================================ ---changeset hs-office-person-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_person', $idName$ - concat(target.tradeName, target.familyName, target.givenName) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-person-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_person', 'concat(target.tradeName, target.familyName, target.givenName)', - $updates$ - personType = new.personType, - tradeName = new.tradeName, - givenName = new.givenName, - familyName = new.familyName - $updates$); ---// - - --- ============================================================================ ---changeset hs-office-person-rbac-NEW-PERSON:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-person and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-person permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-person']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficePersonNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-person not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_person_insert_trigger - before insert - on hs_office_person - for each row - -- TODO.spec: who is allowed to create new persons - when ( not hasAssumedRole() ) -execute procedure addHsOfficePersonNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/223-hs-office-partner-rbac.md b/src/main/resources/db/changelog/223-hs-office-partner-rbac.md deleted file mode 100644 index 148343c3..00000000 --- a/src/main/resources/db/changelog/223-hs-office-partner-rbac.md +++ /dev/null @@ -1,78 +0,0 @@ -### hs_office_partner RBAC - -```mermaid -flowchart TB - -subgraph global - style global fill:#eee - - role:global.admin[global.admin] -end - -subgraph hsOfficeContact - direction TB - style hsOfficeContact fill:#eee - - role:hsOfficeContact.admin[contact.admin] - --> role:hsOfficeContact.tenant[contact.tenant] - --> role:hsOfficeContact.guest[contact.guest] -end - -subgraph hsOfficePerson - direction TB - style hsOfficePerson fill:#eee - - role:hsOfficePerson.admin[person.admin] - --> role:hsOfficePerson.tenant[person.tenant] - --> role:hsOfficePerson.guest[person.guest] -end - -subgraph hsOfficePartnerDetails - direction TB - - perm:hsOfficePartnerDetails.*{{partner.*}} - perm:hsOfficePartnerDetails.edit{{partner.edit}} - perm:hsOfficePartnerDetails.view{{partner.view}} -end - -subgraph hsOfficePartner - - role:hsOfficePartner.owner[partner.owner] - %% permissions - role:hsOfficePartner.owner --> perm:hsOfficePartner.*{{partner.*}} - role:hsOfficePartner.owner --> perm:hsOfficePartnerDetails.*{{partner.*}} - %% incoming - role:global.admin ---> role:hsOfficePartner.owner - - role:hsOfficePartner.admin[partner.admin] - %% permissions - role:hsOfficePartner.admin --> perm:hsOfficePartner.edit{{partner.edit}} - role:hsOfficePartner.admin --> perm:hsOfficePartnerDetails.edit{{partner.edit}} - %% incoming - role:hsOfficePartner.owner ---> role:hsOfficePartner.admin - %% outgoing - role:hsOfficePartner.admin --> role:hsOfficePerson.tenant - role:hsOfficePartner.admin --> role:hsOfficeContact.tenant - - role:hsOfficePartner.agent[partner.agent] - %% permissions - role:hsOfficePartner.agent --> perm:hsOfficePartnerDetails.view{{partner.view}} - %% incoming - role:hsOfficePartner.admin ---> role:hsOfficePartner.agent - role:hsOfficePerson.admin --> role:hsOfficePartner.agent - role:hsOfficeContact.admin --> role:hsOfficePartner.agent - - role:hsOfficePartner.tenant[partner.tenant] - %% incoming - role:hsOfficePartner.agent --> role:hsOfficePartner.tenant - %% outgoing - role:hsOfficePartner.tenant --> role:hsOfficePerson.guest - role:hsOfficePartner.tenant --> role:hsOfficeContact.guest - - role:hsOfficePartner.guest[partner.guest] - %% permissions - role:hsOfficePartner.guest --> perm:hsOfficePartner.view{{partner.view}} - %% incoming - role:hsOfficePartner.tenant --> role:hsOfficePartner.guest -end -``` diff --git a/src/main/resources/db/changelog/223-hs-office-partner-rbac.sql b/src/main/resources/db/changelog/223-hs-office-partner-rbac.sql deleted file mode 100644 index 5757efc9..00000000 --- a/src/main/resources/db/changelog/223-hs-office-partner-rbac.sql +++ /dev/null @@ -1,232 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-partner-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_partner'); ---// - - --- ============================================================================ ---changeset hs-office-partner-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficePartner', 'hs_office_partner'); ---// - - --- ============================================================================ ---changeset hs-office-partner-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the roles and their assignments for partner entities. - */ - -create or replace function hsOfficePartnerRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - oldPerson hs_office_person; - newPerson hs_office_person; - oldContact hs_office_contact; - newContact hs_office_contact; -begin - - select * from hs_office_person as p where p.uuid = NEW.personUuid into newPerson; - select * from hs_office_contact as c where c.uuid = NEW.contactUuid into newContact; - - if TG_OP = 'INSERT' then - - -- === ATTENTION: code generated from related Mermaid flowchart: === - - perform createRoleWithGrants( - hsOfficePartnerOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()] - ); - - perform createRoleWithGrants( - hsOfficePartnerAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[ - hsOfficePartnerOwner(NEW)], - outgoingSubRoles => array[ - hsOfficePersonTenant(newPerson), - hsOfficeContactTenant(newContact)] - ); - - perform createRoleWithGrants( - hsOfficePartnerAgent(NEW), - incomingSuperRoles => array[ - hsOfficePartnerAdmin(NEW), - hsOfficePersonAdmin(newPerson), - hsOfficeContactAdmin(newContact)] - ); - - perform createRoleWithGrants( - hsOfficePartnerTenant(NEW), - incomingSuperRoles => array[ - hsOfficePartnerAgent(NEW)], - outgoingSubRoles => array[ - hsOfficePersonGuest(newPerson), - hsOfficeContactGuest(newContact)] - ); - - perform createRoleWithGrants( - hsOfficePartnerGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficePartnerTenant(NEW)] - ); - - -- === END of code generated from Mermaid flowchart. === - - -- Each partner-details entity belong exactly to one partner entity - -- and it makes little sense just to delegate partner-details roles. - -- Therefore, we did not model partner-details roles, - -- but instead just assign extra permissions to existing partner-roles. - - --Attention: Cannot be in partner-details because of insert order (partner is not in database yet) - - call grantPermissionsToRole( - getRoleId(hsOfficePartnerOwner(NEW), 'fail'), - createPermissions(NEW.detailsUuid, array ['*']) - ); - - call grantPermissionsToRole( - getRoleId(hsOfficePartnerAdmin(NEW), 'fail'), - createPermissions(NEW.detailsUuid, array ['edit']) - ); - - call grantPermissionsToRole( - -- Yes, here hsOfficePartnerAGENT is used, not hsOfficePartnerTENANT. - -- Do NOT grant view permission on partner-details to hsOfficePartnerTENANT! - -- Otherwise package-admins etc. would be able to read the data. - getRoleId(hsOfficePartnerAgent(NEW), 'fail'), - createPermissions(NEW.detailsUuid, array ['view']) - ); - - - elsif TG_OP = 'UPDATE' then - - if OLD.personUuid <> NEW.personUuid then - select * from hs_office_person as p where p.uuid = OLD.personUuid into oldPerson; - - call revokeRoleFromRole(hsOfficePersonTenant(oldPerson), hsOfficePartnerAdmin(OLD)); - call grantRoleToRole(hsOfficePersonTenant(newPerson), hsOfficePartnerAdmin(NEW)); - - call revokeRoleFromRole(hsOfficePartnerAgent(OLD), hsOfficePersonAdmin(oldPerson)); - call grantRoleToRole(hsOfficePartnerAgent(NEW), hsOfficePersonAdmin(newPerson)); - - call revokeRoleFromRole(hsOfficePersonGuest(oldPerson), hsOfficePartnerTenant(OLD)); - call grantRoleToRole(hsOfficePersonGuest(newPerson), hsOfficePartnerTenant(NEW)); - end if; - - if OLD.contactUuid <> NEW.contactUuid then - select * from hs_office_contact as c where c.uuid = OLD.contactUuid into oldContact; - - call revokeRoleFromRole(hsOfficeContactTenant(oldContact), hsOfficePartnerAdmin(OLD)); - call grantRoleToRole(hsOfficeContactTenant(newContact), hsOfficePartnerAdmin(NEW)); - - call revokeRoleFromRole(hsOfficePartnerAgent(OLD), hsOfficeContactAdmin(oldContact)); - call grantRoleToRole(hsOfficePartnerAgent(NEW), hsOfficeContactAdmin(newContact)); - - call revokeRoleFromRole(hsOfficeContactGuest(oldContact), hsOfficePartnerTenant(OLD)); - call grantRoleToRole(hsOfficeContactGuest(newContact), hsOfficePartnerTenant(NEW)); - end if; - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficePartner_Trigger - after insert - on hs_office_partner - for each row -execute procedure hsOfficePartnerRbacRolesTrigger(); - -/* - An AFTER UPDATE TRIGGER which updates the role structure of a customer. - */ -create trigger updateRbacRolesForHsOfficePartner_Trigger - after update - on hs_office_partner - for each row -execute procedure hsOfficePartnerRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-partner-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_partner', $idName$ - partnerNumber || ':' || - (select idName from hs_office_person_iv p where p.uuid = target.personuuid) - || '-' || - (select idName from hs_office_contact_iv c where c.uuid = target.contactuuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-partner-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_partner', - '(select idName from hs_office_person_iv p where p.uuid = target.personUuid)', - $updates$ - personUuid = new.personUuid, - contactUuid = new.contactUuid - $updates$); ---// - - --- ============================================================================ ---changeset hs-office-partner-rbac-NEW-PARTNER:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-partner and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-partner permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-partner']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficePartnerNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-partner not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_partner_insert_trigger - before insert - on hs_office_partner - for each row - -- TODO.spec: who is allowed to create new partners - when ( not hasAssumedRole() ) -execute procedure addHsOfficePartnerNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/224-hs-office-partner-details-rbac.sql b/src/main/resources/db/changelog/224-hs-office-partner-details-rbac.sql deleted file mode 100644 index ab94481e..00000000 --- a/src/main/resources/db/changelog/224-hs-office-partner-details-rbac.sql +++ /dev/null @@ -1,86 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-partner-details-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_partner_details'); ---// - - - - - --- ============================================================================ ---changeset hs-office-partner-details-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_partner_details', $idName$ - (select idName || '-details' from hs_office_partner_iv partner_iv - join hs_office_partner partner on (partner_iv.uuid = partner.uuid) - where partner.detailsUuid = target.uuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-partner-details-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_partner_details', - 'target.uuid', -- no specific order required - $updates$ - registrationOffice = new.registrationOffice, - registrationNumber = new.registrationNumber, - birthPlace = new.birthPlace, - birthName = new.birthName, - birthday = new.birthday, - dateOfDeath = new.dateOfDeath - $updates$); ---// - - --- ============================================================================ ---changeset hs-office-partner-details-rbac-NEW-CONTACT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-partner-details and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-partner-details permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-partner-details']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - --- TODO.refa: the code below could be moved to a generator, maybe even the code above. --- Additionally, the code below is not neccesary for all entities, specifiy when it is! - -/** - Used by the trigger to prevent the add-partner-details to current user respectively assumed roles. - */ -create or replace function addHsOfficePartnerDetailsNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-partner-details not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create new partner-details. - */ -create trigger hs_office_partner_details_insert_trigger - before insert - on hs_office_partner_details - for each row - when ( not hasAssumedRole() ) -execute procedure addHsOfficePartnerDetailsNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql b/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql deleted file mode 100644 index a4705002..00000000 --- a/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql +++ /dev/null @@ -1,72 +0,0 @@ ---liquibase formatted sql - - --- ============================================================================ ---changeset hs-office-partner-TEST-DATA-GENERATOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a single partner test record. - */ -create or replace procedure createHsOfficePartnerTestData( - partnerNumber numeric(5), - personTradeOrFamilyName varchar, - contactLabel varchar ) - language plpgsql as $$ -declare - currentTask varchar; - idName varchar; - relatedPerson hs_office_person; - relatedContact hs_office_contact; - relatedDetailsUuid uuid; -begin - idName := cleanIdentifier( personTradeOrFamilyName|| '-' || contactLabel); - currentTask := 'creating partner test-data ' || idName; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - - select p.* from hs_office_person p - where p.tradeName = personTradeOrFamilyName or p.familyName = personTradeOrFamilyName - into relatedPerson; - select c.* from hs_office_contact c - where c.label = contactLabel - into relatedContact; - - raise notice 'creating test partner: %', idName; - raise notice '- using person (%): %', relatedPerson.uuid, relatedPerson; - raise notice '- using contact (%): %', relatedContact.uuid, relatedContact; - - if relatedPerson.persontype = 'NP' then - insert - into hs_office_partner_details (uuid, birthName, birthday, birthPlace) - values (uuid_generate_v4(), 'Meyer', '1987-10-31', 'Hamburg') - returning uuid into relatedDetailsUuid; - else - insert - into hs_office_partner_details (uuid, registrationOffice, registrationNumber) - values (uuid_generate_v4(), 'Hamburg', '12345') - returning uuid into relatedDetailsUuid; - end if; - - insert - into hs_office_partner (uuid, partnerNumber, personuuid, contactuuid, detailsUuid) - values (uuid_generate_v4(), partnerNumber, relatedPerson.uuid, relatedContact.uuid, relatedDetailsUuid); -end; $$; ---// - - - --- ============================================================================ ---changeset hs-office-partner-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// --- ---------------------------------------------------------------------------- - -do language plpgsql $$ - begin - call createHsOfficePartnerTestData(10001, 'First GmbH', 'first contact'); - call createHsOfficePartnerTestData(10002, 'Second e.K.', 'second contact'); - call createHsOfficePartnerTestData(10003, 'Third OHG', 'third contact'); - call createHsOfficePartnerTestData(10004, 'Fourth e.G.', 'forth contact'); - call createHsOfficePartnerTestData(10010, 'Smith', 'fifth contact'); - end; -$$; ---// diff --git a/src/main/resources/db/changelog/230-hs-office-relationship.sql b/src/main/resources/db/changelog/230-hs-office-relationship.sql deleted file mode 100644 index 18d21da2..00000000 --- a/src/main/resources/db/changelog/230-hs-office-relationship.sql +++ /dev/null @@ -1,35 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-relationship-MAIN-TABLE:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -CREATE TYPE HsOfficeRelationshipType AS ENUM ( - 'UNKNOWN', - 'EX_PARTNER', - 'REPRESENTATIVE', - 'VIP_CONTACT', - 'ACCOUNTING', - 'OPERATIONS', - 'SUBSCRIBER'); - -CREATE CAST (character varying as HsOfficeRelationshipType) WITH INOUT AS IMPLICIT; - -create table if not exists hs_office_relationship -( - uuid uuid unique references RbacObject (uuid) initially deferred, -- on delete cascade - relAnchorUuid uuid not null references hs_office_person(uuid), - relHolderUuid uuid not null references hs_office_person(uuid), - contactUuid uuid references hs_office_contact(uuid), - relType HsOfficeRelationshipType not null, - relMark varchar(24) -); ---// - - --- ============================================================================ ---changeset hs-office-relationship-MAIN-TABLE-JOURNAL:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -call create_journal('hs_office_relationship'); ---// diff --git a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.md b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.md deleted file mode 100644 index c41de32c..00000000 --- a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.md +++ /dev/null @@ -1,192 +0,0 @@ -### hs_office_relationship RBAC - -```mermaid - -flowchart TB - -subgraph global - style global fill:#eee - - role:global.admin[global.admin] -end - -subgraph hsOfficeContact - direction TB - style hsOfficeContact fill:#eee - - role:hsOfficeContact.admin[contact.admin] - --> role:hsOfficeContact.tenant[contact.tenant] - --> role:hsOfficeContact.guest[contact.guest] -end - -subgraph hsOfficePerson - direction TB - style hsOfficePerson fill:#eee - - role:hsOfficePerson.admin[person.admin] - --> role:hsOfficePerson.tenant[person.tenant] - --> role:hsOfficePerson.guest[person.guest] -end - -subgraph hsOfficeRelationship - - role:hsOfficePerson#relAnchor.admin[person#anchor.admin] - --- role:hsOfficePerson.admin - - role:hsOfficeRelationship.owner[relationship.owner] - %% permissions - role:hsOfficeRelationship.owner --> perm:hsOfficeRelationship.*{{relationship.*}} - %% incoming - role:global.admin ---> role:hsOfficeRelationship.owner - role:hsOfficePersonAdmin#relAnchor.admin -end -``` - - if TG_OP = 'INSERT' then - - -- the owner role with full access for admins of the relAnchor global admins - ownerRole = createRole( - hsOfficeRelationshipOwner(NEW), - grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']), - beneathRoles(array[ - globalAdmin(), - hsOfficePersonAdmin(newRelAnchor)]) - ); - - -- the admin role with full access for the owner - adminRole = createRole( - hsOfficeRelationshipAdmin(NEW), - grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']), - beneathRole(ownerRole) - ); - - -- the tenant role for those related users who can view the data - perform createRole( - hsOfficeRelationshipTenant, - grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']), - beneathRoles(array[ - hsOfficePersonAdmin(newRelAnchor), - hsOfficePersonAdmin(newRelHolder), - hsOfficeContactAdmin(newContact)]), - withSubRoles(array[ - hsOfficePersonTenant(newRelAnchor), - hsOfficePersonTenant(newRelHolder), - hsOfficeContactTenant(newContact)]) - ); - - -- anchor and holder admin roles need each others tenant role - -- to be able to see the joined relationship - call grantRoleToRole(hsOfficePersonTenant(newRelAnchor), hsOfficePersonAdmin(newRelHolder)); - call grantRoleToRole(hsOfficePersonTenant(newRelHolder), hsOfficePersonAdmin(newRelAnchor)); - call grantRoleToRoleIfNotNull(hsOfficePersonTenant(newRelHolder), hsOfficeContactAdmin(newContact)); - - elsif TG_OP = 'UPDATE' then - - if OLD.contactUuid <> NEW.contactUuid then - -- nothing but the contact can be updated, - -- in other cases, a new relationship needs to be created and the old updated - - select * from hs_office_contact as c where c.uuid = OLD.contactUuid into oldContact; - - call revokeRoleFromRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(oldContact) ); - call grantRoleToRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(newContact) ); - - call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeRelationshipTenant ); - call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeRelationshipTenant ); - end if; - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeRelationship_Trigger - after insert - on hs_office_relationship - for each row -execute procedure hsOfficeRelationshipRbacRolesTrigger(); - -/* - An AFTER UPDATE TRIGGER which updates the role structure of a customer. - */ -create trigger updateRbacRolesForHsOfficeRelationship_Trigger - after update - on hs_office_relationship - for each row -execute procedure hsOfficeRelationshipRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_relationship', $idName$ - (select idName from hs_office_person_iv p where p.uuid = target.relAnchorUuid) - || '-with-' || target.relType || '-' || - (select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_relationship', - '(select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid)', - $updates$ - contactUuid = new.contactUuid - $updates$); ---// - --- TODO: exception if one tries to amend any other column - - --- ============================================================================ ---changeset hs-office-relationship-rbac-NEW-RELATHIONSHIP:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-relationship and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-relationship permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-relationship']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeRelationshipNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-relationship not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_relationship_insert_trigger - before insert - on hs_office_relationship - for each row - -- TODO.spec: who is allowed to create new relationships - when ( not hasAssumedRole() ) -execute procedure addHsOfficeRelationshipNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql deleted file mode 100644 index 03b0b748..00000000 --- a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql +++ /dev/null @@ -1,190 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-relationship-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_relationship'); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeRelationship', 'hs_office_relationship'); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the roles and their assignments for relationship entities. - */ - -create or replace function hsOfficeRelationshipRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - hsOfficeRelationshipTenant RbacRoleDescriptor; - newRelAnchor hs_office_person; - newRelHolder hs_office_person; - oldContact hs_office_contact; - newContact hs_office_contact; -begin - - hsOfficeRelationshipTenant := hsOfficeRelationshipTenant(NEW); - - select * from hs_office_person as p where p.uuid = NEW.relAnchorUuid into newRelAnchor; - select * from hs_office_person as p where p.uuid = NEW.relHolderUuid into newRelHolder; - select * from hs_office_contact as c where c.uuid = NEW.contactUuid into newContact; - - if TG_OP = 'INSERT' then - - perform createRoleWithGrants( - hsOfficeRelationshipOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[ - globalAdmin(), - hsOfficePersonAdmin(newRelAnchor)] - ); - - perform createRoleWithGrants( - hsOfficeRelationshipAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficeRelationshipOwner(NEW)] - ); - - -- the tenant role for those related users who can view the data - perform createRoleWithGrants( - hsOfficeRelationshipTenant, - permissions => array['view'], - incomingSuperRoles => array[ - hsOfficeRelationshipAdmin(NEW), - hsOfficePersonAdmin(newRelAnchor), - hsOfficePersonAdmin(newRelHolder), - hsOfficeContactAdmin(newContact)], - outgoingSubRoles => array[ - hsOfficePersonTenant(newRelAnchor), - hsOfficePersonTenant(newRelHolder), - hsOfficeContactTenant(newContact)] - ); - - -- anchor and holder admin roles need each others tenant role - -- to be able to see the joined relationship - -- TODO: this can probably be avoided through agent+guest roles - call grantRoleToRole(hsOfficePersonTenant(newRelAnchor), hsOfficePersonAdmin(newRelHolder)); - call grantRoleToRole(hsOfficePersonTenant(newRelHolder), hsOfficePersonAdmin(newRelAnchor)); - call grantRoleToRoleIfNotNull(hsOfficePersonTenant(newRelHolder), hsOfficeContactAdmin(newContact)); - - elsif TG_OP = 'UPDATE' then - - if OLD.contactUuid <> NEW.contactUuid then - -- nothing but the contact can be updated, - -- in other cases, a new relationship needs to be created and the old updated - - select * from hs_office_contact as c where c.uuid = OLD.contactUuid into oldContact; - - call revokeRoleFromRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(oldContact) ); - call grantRoleToRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(newContact) ); - - call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeRelationshipTenant ); - call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeRelationshipTenant ); - end if; - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeRelationship_Trigger - after insert - on hs_office_relationship - for each row -execute procedure hsOfficeRelationshipRbacRolesTrigger(); - -/* - An AFTER UPDATE TRIGGER which updates the role structure of a customer. - */ -create trigger updateRbacRolesForHsOfficeRelationship_Trigger - after update - on hs_office_relationship - for each row -execute procedure hsOfficeRelationshipRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_relationship', $idName$ - (select idName from hs_office_person_iv p where p.uuid = target.relAnchorUuid) - || '-with-' || target.relType || '-' || - (select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-relationship-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_relationship', - '(select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid)', - $updates$ - contactUuid = new.contactUuid - $updates$); ---// - --- TODO: exception if one tries to amend any other column - - --- ============================================================================ ---changeset hs-office-relationship-rbac-NEW-RELATHIONSHIP:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-relationship and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-relationship permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-relationship']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeRelationshipNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-relationship not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_relationship_insert_trigger - before insert - on hs_office_relationship - for each row - -- TODO.spec: who is allowed to create new relationships - when ( not hasAssumedRole() ) -execute procedure addHsOfficeRelationshipNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql b/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql deleted file mode 100644 index 534ae512..00000000 --- a/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql +++ /dev/null @@ -1,84 +0,0 @@ ---liquibase formatted sql - - --- ============================================================================ ---changeset hs-office-relationship-TEST-DATA-GENERATOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a single relationship test record. - */ -create or replace procedure createHsOfficeRelationshipTestData( - anchorPersonTradeName varchar, - holderPersonFamilyName varchar, - relationshipType HsOfficeRelationshipType, - contactLabel varchar, - mark varchar default null) - language plpgsql as $$ -declare - currentTask varchar; - idName varchar; - anchorPerson hs_office_person; - holderPerson hs_office_person; - contact hs_office_contact; - -begin - idName := cleanIdentifier( anchorPersonTradeName || '-' || holderPersonFamilyName); - currentTask := 'creating relationship test-data ' || idName; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - - select p.* from hs_office_person p where p.tradeName = anchorPersonTradeName into anchorPerson; - select p.* from hs_office_person p where p.familyName = holderPersonFamilyName into holderPerson; - select c.* from hs_office_contact c where c.label = contactLabel into contact; - - raise notice 'creating test relationship: %', idName; - raise notice '- using anchor person (%): %', anchorPerson.uuid, anchorPerson; - raise notice '- using holder person (%): %', holderPerson.uuid, holderPerson; - raise notice '- using contact (%): %', contact.uuid, contact; - insert - into hs_office_relationship (uuid, relanchoruuid, relholderuuid, reltype, relmark, contactUuid) - values (uuid_generate_v4(), anchorPerson.uuid, holderPerson.uuid, relationshipType, mark, contact.uuid); -end; $$; ---// - -/* - Creates a range of test relationship for mass data generation. - */ -create or replace procedure createHsOfficeRelationshipTestData( - startCount integer, -- count of auto generated rows before the run - endCount integer -- count of auto generated rows after the run -) - language plpgsql as $$ -declare - person hs_office_person; - contact hs_office_contact; -begin - for t in startCount..endCount - loop - select p.* from hs_office_person p where tradeName = intToVarChar(t, 4) into person; - select c.* from hs_office_contact c where c.label = intToVarChar(t, 4) || '#' || t into contact; - - call createHsOfficeRelationshipTestData(person.uuid, contact.uuid, 'REPRESENTATIVE'); - commit; - end loop; -end; $$; ---// - - --- ============================================================================ ---changeset hs-office-relationship-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// --- ---------------------------------------------------------------------------- - -do language plpgsql $$ - begin - call createHsOfficeRelationshipTestData('First GmbH', 'Smith', 'REPRESENTATIVE', 'first contact'); - - call createHsOfficeRelationshipTestData('Second e.K.', 'Smith', 'REPRESENTATIVE', 'second contact'); - - call createHsOfficeRelationshipTestData('Third OHG', 'Smith', 'REPRESENTATIVE', 'third contact'); - - call createHsOfficeRelationshipTestData('Third OHG', 'Smith', 'SUBSCRIBER', 'third contact', 'members-announce'); - end; -$$; ---// diff --git a/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.md b/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.md deleted file mode 100644 index fc34f147..00000000 --- a/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.md +++ /dev/null @@ -1,40 +0,0 @@ -### hs_office_bankaccount RBAC Roles - -```mermaid -flowchart TB - -subgraph global - style hsOfficeBankAccount fill: #e9f7ef - - role:global.admin[global.admin] -end - -subgraph hsOfficeBankAccount - direction TB - style hsOfficeBankAccount fill: #e9f7ef - - user:hsOfficeBankAccount.creator([bankAccount.creator]) - - role:hsOfficeBankAccount.owner[[bankAccount.owner]] - %% permissions - role:hsOfficeBankAccount.owner --> perm:hsOfficeBankAccount.*{{hsOfficeBankAccount.delete}} - %% incoming - role:global.admin --> role:hsOfficeBankAccount.owner - user:hsOfficeBankAccount.creator ---> role:hsOfficeBankAccount.owner - - role:hsOfficeBankAccount.admin[[bankAccount.admin]] - %% incoming - role:hsOfficeBankAccount.owner ---> role:hsOfficeBankAccount.admin - - role:hsOfficeBankAccount.tenant[[bankAccount.tenant]] - %% incoming - role:hsOfficeBankAccount.admin ---> role:hsOfficeBankAccount.tenant - - role:hsOfficeBankAccount.guest[[bankAccount.guest]] - %% permissions - role:hsOfficeBankAccount.guest --> perm:hsOfficeBankAccount.view{{hsOfficeBankAccount.view}} - %% incoming - role:hsOfficeBankAccount.tenant ---> role:hsOfficeBankAccount.guest -end -``` - diff --git a/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.sql b/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.sql deleted file mode 100644 index 148e0ee2..00000000 --- a/src/main/resources/db/changelog/243-hs-office-bankaccount-rbac.sql +++ /dev/null @@ -1,139 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_bankaccount'); ---// - - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeBankAccount', 'hs_office_bankaccount'); ---// - - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates the roles and their assignments for a new bankaccount for the AFTER INSERT TRIGGER. - */ - -create or replace function createRbacRolesForHsOfficeBankAccount() - returns trigger - language plpgsql - strict as $$ -begin - if TG_OP <> 'INSERT' then - raise exception 'invalid usage of TRIGGER AFTER INSERT'; - end if; - - perform createRoleWithGrants( - hsOfficeBankAccountOwner(NEW), - permissions => array['delete'], - incomingSuperRoles => array[globalAdmin()], - userUuids => array[currentUserUuid()], - grantedByRole => globalAdmin() - ); - - perform createRoleWithGrants( - hsOfficeBankAccountAdmin(NEW), - incomingSuperRoles => array[hsOfficeBankAccountOwner(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeBankAccountTenant(NEW), - incomingSuperRoles => array[hsOfficeBankAccountAdmin(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeBankAccountGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficeBankAccountTenant(NEW)] - ); - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ - -create trigger createRbacRolesForHsOfficeBankAccount_Trigger - after insert - on hs_office_bankaccount - for each row -execute procedure createRbacRolesForHsOfficeBankAccount(); ---// - - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -call generateRbacIdentityView('hs_office_bankaccount', $idName$ - target.holder - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_bankaccount', 'target.holder', - $updates$ - holder = new.holder, - iban = new.iban, - bic = new.bic - $updates$); ---/ - - --- ============================================================================ ---changeset hs-office-bankaccount-rbac-NEW-BANKACCOUNT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-bankaccount and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-bankaccount permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-bankaccount']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeBankAccountNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-bankaccount not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_bankaccount_insert_trigger - before insert - on hs_office_bankaccount - for each row - -- TODO.spec: who is allowed to create new bankaccounts - when ( not hasAssumedRole() ) -execute procedure addHsOfficeBankAccountNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.md b/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.md deleted file mode 100644 index 78bb7751..00000000 --- a/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.md +++ /dev/null @@ -1,71 +0,0 @@ -### hs_office_sepaMandate RBAC - -```mermaid -flowchart TB - -subgraph global - style global fill:#eee - - role:global.admin[global.admin] -end - -subgraph hsOfficeBankAccount - direction TB - style hsOfficeBankAccount fill:#eee - - role:hsOfficeBankAccount.owner[bankAccount.owner] - --> role:hsOfficeBankAccount.admin[bankAccount.admin] - --> role:hsOfficeBankAccount.tenant[bankAccount.tenant] - --> role:hsOfficeBankAccount.guest[bankAccount.guest] -end - -subgraph hsOfficeDebitor - direction TB - style hsOfficeDebitor fill:#eee - - role:hsOfficeDebitor.owner[debitor.admin] - --> role:hsOfficeDebitor.admin[debitor.admin] - --> role:hsOfficeDebitor.agent[debitor.agent] - --> role:hsOfficeDebitor.tenant[debitor.tenant] - --> role:hsOfficeDebitor.guest[debitor.guest] -end - -subgraph hsOfficeSepaMandate - - role:hsOfficeSepaMandate.owner[sepaMandate.owner] - %% permissions - role:hsOfficeSepaMandate.owner --> perm:hsOfficeSepaMandate.*{{sepaMandate.*}} - %% incoming - role:global.admin ---> role:hsOfficeSepaMandate.owner - - role:hsOfficeSepaMandate.admin[sepaMandate.admin] - %% permissions - role:hsOfficeSepaMandate.admin --> perm:hsOfficeSepaMandate.edit{{sepaMandate.edit}} - %% incoming - role:hsOfficeSepaMandate.owner ---> role:hsOfficeSepaMandate.admin - - role:hsOfficeSepaMandate.agent[sepaMandate.agent] - %% incoming - role:hsOfficeSepaMandate.admin ---> role:hsOfficeSepaMandate.agent - role:hsOfficeDebitor.admin --> role:hsOfficeSepaMandate.agent - role:hsOfficeBankAccount.admin --> role:hsOfficeSepaMandate.agent - %% outgoing - role:hsOfficeSepaMandate.agent --> role:hsOfficeDebitor.tenant - role:hsOfficeSepaMandate.admin --> role:hsOfficeBankAccount.tenant - - role:hsOfficeSepaMandate.tenant[sepaMandate.tenant] - %% incoming - role:hsOfficeSepaMandate.agent --> role:hsOfficeSepaMandate.tenant - %% outgoing - role:hsOfficeSepaMandate.tenant --> role:hsOfficeDebitor.guest - role:hsOfficeSepaMandate.tenant --> role:hsOfficeBankAccount.guest - - role:hsOfficeSepaMandate.guest[sepaMandate.guest] - %% permissions - role:hsOfficeSepaMandate.guest --> perm:hsOfficeSepaMandate.view{{sepaMandate.view}} - %% incoming - role:hsOfficeSepaMandate.tenant --> role:hsOfficeSepaMandate.guest -end - - -``` diff --git a/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.sql b/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.sql deleted file mode 100644 index f09f2a4b..00000000 --- a/src/main/resources/db/changelog/253-hs-office-sepamandate-rbac.sql +++ /dev/null @@ -1,156 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_sepamandate'); ---// - - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeSepaMandate', 'hs_office_sepamandate'); ---// - - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the roles and their assignments for sepaMandate entities. - */ - -create or replace function hsOfficeSepaMandateRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - newHsOfficeDebitor hs_office_debitor; - newHsOfficeBankAccount hs_office_bankAccount; -begin - - select * from hs_office_debitor as p where p.uuid = NEW.debitorUuid into newHsOfficeDebitor; - select * from hs_office_bankAccount as c where c.uuid = NEW.bankAccountUuid into newHsOfficeBankAccount; - - if TG_OP = 'INSERT' then - - -- === ATTENTION: code generated from related Mermaid flowchart: === - - perform createRoleWithGrants( - hsOfficeSepaMandateOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()] - ); - - perform createRoleWithGrants( - hsOfficeSepaMandateAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficeSepaMandateOwner(NEW)], - outgoingSubRoles => array[hsOfficeBankAccountTenant(newHsOfficeBankAccount)] - ); - - perform createRoleWithGrants( - hsOfficeSepaMandateAgent(NEW), - incomingSuperRoles => array[hsOfficeSepaMandateAdmin(NEW), hsOfficeDebitorAdmin(newHsOfficeDebitor), hsOfficeBankAccountAdmin(newHsOfficeBankAccount)], - outgoingSubRoles => array[hsOfficeDebitorTenant(newHsOfficeDebitor)] - ); - - perform createRoleWithGrants( - hsOfficeSepaMandateTenant(NEW), - incomingSuperRoles => array[hsOfficeSepaMandateAgent(NEW)], - outgoingSubRoles => array[hsOfficeDebitorGuest(newHsOfficeDebitor), hsOfficeBankAccountGuest(newHsOfficeBankAccount)] - ); - - perform createRoleWithGrants( - hsOfficeSepaMandateGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficeSepaMandateTenant(NEW)] - ); - - -- === END of code generated from Mermaid flowchart. === - - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeSepaMandate_Trigger - after insert - on hs_office_sepamandate - for each row -execute procedure hsOfficeSepaMandateRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_sepamandate', idNameExpression => 'target.reference'); ---// - - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_sepamandate', - orderby => 'target.reference', - columnUpdates => $updates$ - reference = new.reference, - agreement = new.agreement, - validity = new.validity - $updates$); ---// - - --- ============================================================================ ---changeset hs-office-sepamandate-rbac-NEW-SepaMandate:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-sepaMandate and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-sepaMandate permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-sepamandate']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeSepaMandateNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-sepaMandate not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_sepamandate_insert_trigger - before insert - on hs_office_sepamandate - for each row - -- TODO.spec: who is allowed to create new sepaMandates - when ( not hasAssumedRole() ) -execute procedure addHsOfficeSepaMandateNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/258-hs-office-sepamandate-test-data.sql b/src/main/resources/db/changelog/258-hs-office-sepamandate-test-data.sql deleted file mode 100644 index eb96d1a0..00000000 --- a/src/main/resources/db/changelog/258-hs-office-sepamandate-test-data.sql +++ /dev/null @@ -1,51 +0,0 @@ ---liquibase formatted sql - - --- ============================================================================ ---changeset hs-office-sepaMandate-TEST-DATA-GENERATOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a single sepaMandate test record. - */ -create or replace procedure createHsOfficeSepaMandateTestData( tradeNameAndHolderName varchar ) - language plpgsql as $$ -declare - currentTask varchar; - idName varchar; - relatedDebitor hs_office_debitor; - relatedBankAccount hs_office_bankAccount; -begin - idName := cleanIdentifier( tradeNameAndHolderName); - currentTask := 'creating SEPA-mandate test-data ' || idName; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - - select debitor.* from hs_office_debitor debitor - join hs_office_partner parter on parter.uuid = debitor.partnerUuid - join hs_office_person person on person.uuid = parter.personUuid - where person.tradeName = tradeNameAndHolderName into relatedDebitor; - select c.* from hs_office_bankAccount c where c.holder = tradeNameAndHolderName into relatedBankAccount; - - raise notice 'creating test SEPA-mandate: %', idName; - raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; - raise notice '- using bankAccount (%): %', relatedBankAccount.uuid, relatedBankAccount; - insert - into hs_office_sepamandate (uuid, debitoruuid, bankAccountuuid, reference, agreement, validity) - values (uuid_generate_v4(), relatedDebitor.uuid, relatedBankAccount.uuid, 'ref'||idName, '20220930', daterange('20221001' , '20261231', '[]')); -end; $$; ---// - - --- ============================================================================ ---changeset hs-office-sepaMandate-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// --- ---------------------------------------------------------------------------- - -do language plpgsql $$ - begin - call createHsOfficeSepaMandateTestData('First GmbH'); - call createHsOfficeSepaMandateTestData('Second e.K.'); - call createHsOfficeSepaMandateTestData('Third OHG'); - end; -$$; ---// diff --git a/src/main/resources/db/changelog/270-hs-office-debitor.sql b/src/main/resources/db/changelog/270-hs-office-debitor.sql deleted file mode 100644 index fae4e90c..00000000 --- a/src/main/resources/db/changelog/270-hs-office-debitor.sql +++ /dev/null @@ -1,33 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-debitor-MAIN-TABLE:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -create table hs_office_debitor -( - uuid uuid unique references RbacObject (uuid) initially deferred, - partnerUuid uuid not null references hs_office_partner(uuid), - billable boolean not null default true, - debitorNumberSuffix numeric(2) not null, - billingContactUuid uuid not null references hs_office_contact(uuid), - vatId varchar(24), -- TODO.spec: here or in person? - vatCountryCode varchar(2), - vatBusiness boolean not null, - vatReverseCharge boolean not null, - refundBankAccountUuid uuid references hs_office_bankaccount(uuid), - defaultPrefix char(3) not null unique - constraint check_default_prefix check ( - defaultPrefix::text ~ '^([a-z]{3}|al0|bh1|c4s|f3k|k8i|l3d|mh1|o13|p2m|s80|t4w)$' - ) - -- TODO.impl: SEPA-mandate -); ---// - - --- ============================================================================ ---changeset hs-office-debitor-MAIN-TABLE-JOURNAL:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -call create_journal('hs_office_debitor'); ---// diff --git a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md deleted file mode 100644 index 6830a7b1..00000000 --- a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md +++ /dev/null @@ -1,250 +0,0 @@ -### hs_office_debitor RBAC Roles - -```mermaid -flowchart TB - -subgraph global - style global fill:#eee - - role:global.admin[global.admin] -end - -subgraph office - style office fill:#eee - - subgraph sepa - - subgraph bankaccount - style bankaccount fill: #e9f7ef - - user:hsOfficeBankAccount.creator([bankaccount.creator]) - - role:hsOfficeBankAccount.owner[bankaccount.owner] - %% permissions - role:hsOfficeBankAccount.owner --> perm:hsOfficeBankAccount.*{{bankaccount.*}} - %% incoming - role:global.admin --> role:hsOfficeBankAccount.owner - user:hsOfficeBankAccount.creator ---> role:hsOfficeBankAccount.owner - - role:hsOfficeBankAccount.admin[bankaccount.admin] - %% permissions - role:hsOfficeBankAccount.admin --> perm:hsOfficeBankAccount.edit{{bankaccount.edit}} - %% incoming - role:hsOfficeBankAccount.owner ---> role:hsOfficeBankAccount.admin - - role:hsOfficeBankAccount.tenant[bankaccount.tenant] - %% incoming - role:hsOfficeBankAccount.admin ---> role:hsOfficeBankAccount.tenant - - role:hsOfficeBankAccount.guest[bankaccount.guest] - %% permissions - role:hsOfficeBankAccount.guest --> perm:hsOfficeBankAccount.view{{bankaccount.view}} - %% incoming - role:hsOfficeBankAccount.tenant ---> role:hsOfficeBankAccount.guest - end - - subgraph hsOfficeSepaMandate - end - - end - - subgraph contact - style contact fill: #e9f7ef - - user:hsOfficeContact.creator([contact.creator]) - - role:hsOfficeContact.owner[contact.owner] - %% permissions - role:hsOfficeContact.owner --> perm:hsOfficeContact.*{{contact.*}} - %% incoming - role:global.admin --> role:hsOfficeContact.owner - user:hsOfficeContact.creator ---> role:hsOfficeContact.owner - - role:hsOfficeContact.admin[contact.admin] - %% permissions - role:hsOfficeContact.admin ---> perm:hsOfficeContact.edit{{contact.edit}} - %% incoming - role:hsOfficeContact.owner ---> role:hsOfficeContact.admin - - role:hsOfficeContact.tenant[contact.tenant] - %% incoming - role:hsOfficeContact.admin ----> role:hsOfficeContact.tenant - - role:hsOfficeContact.guest[contact.guest] - %% permissions - role:hsOfficeContact.guest --> perm:hsOfficeContact.view{{contact.view}} - %% incoming - role:hsOfficeContact.tenant ---> role:hsOfficeContact.guest - end - - subgraph partner-person - - subgraph person - style person fill: #e9f7ef - - user:hsOfficePerson.creator([personcreator]) - - role:hsOfficePerson.owner[person.owner] - %% permissions - role:hsOfficePerson.owner --> perm:hsOfficePerson.*{{person.*}} - %% incoming - user:hsOfficePerson.creator ---> role:hsOfficePerson.owner - role:global.admin --> role:hsOfficePerson.owner - - role:hsOfficePerson.admin[person.admin] - %% permissions - role:hsOfficePerson.admin --> perm:hsOfficePerson.edit{{person.edit}} - %% incoming - role:hsOfficePerson.owner ---> role:hsOfficePerson.admin - - role:hsOfficePerson.tenant[person.tenant] - %% incoming - role:hsOfficePerson.admin -----> role:hsOfficePerson.tenant - - role:hsOfficePerson.guest[person.guest] - %% permissions - role:hsOfficePerson.guest --> perm:hsOfficePerson.edit{{person.view}} - %% incoming - role:hsOfficePerson.tenant ---> role:hsOfficePerson.guest - end - - subgraph partner - - role:hsOfficePartner.owner[partner.owner] - %% permissions - role:hsOfficePartner.owner --> perm:hsOfficePartner.*{{partner.*}} - %% incoming - role:global.admin ---> role:hsOfficePartner.owner - - role:hsOfficePartner.admin[partner.admin] - %% permissions - role:hsOfficePartner.admin --> perm:hsOfficePartner.edit{{partner.edit}} - %% incoming - role:hsOfficePartner.owner ---> role:hsOfficePartner.admin - %% outgoing - role:hsOfficePartner.admin --> role:hsOfficePerson.tenant - role:hsOfficePartner.admin --> role:hsOfficeContact.tenant - - role:hsOfficePartner.agent[partner.agent] - %% incoming - role:hsOfficePartner.admin --> role:hsOfficePartner.agent - role:hsOfficePerson.admin --> role:hsOfficePartner.agent - role:hsOfficeContact.admin --> role:hsOfficePartner.agent - - role:hsOfficePartner.tenant[partner.tenant] - %% incoming - role:hsOfficePartner.agent ---> role:hsOfficePartner.tenant - %% outgoing - role:hsOfficePartner.tenant --> role:hsOfficePerson.guest - role:hsOfficePartner.tenant --> role:hsOfficeContact.guest - - role:hsOfficePartner.guest[partner.guest] - %% permissions - role:hsOfficePartner.guest --> perm:hsOfficePartner.view{{partner.view}} - %% incoming - role:hsOfficePartner.tenant ---> role:hsOfficePartner.guest - end - - end - - subgraph debitor - style debitor stroke-width:6px - - user:hsOfficeDebitor.creator([debitor.creator]) - %% created by role - user:hsOfficeDebitor.creator --> role:hsOfficePartner.agent - - role:hsOfficeDebitor.owner[debitor.owner] - %% permissions - role:hsOfficeDebitor.owner --> perm:hsOfficeDebitor.*{{debitor.*}} - %% incoming - user:hsOfficeDebitor.creator --> role:hsOfficeDebitor.owner - role:global.admin --> role:hsOfficeDebitor.owner - - role:hsOfficeDebitor.admin[debitor.admin] - %% permissions - role:hsOfficeDebitor.admin --> perm:hsOfficeDebitor.edit{{debitor.edit}} - %% incoming - role:hsOfficeDebitor.owner ---> role:hsOfficeDebitor.admin - - role:hsOfficeDebitor.agent[debitor.agent] - %% incoming - role:hsOfficeDebitor.admin ---> role:hsOfficeDebitor.agent - role:hsOfficePartner.admin --> role:hsOfficeDebitor.agent - %% outgoing - role:hsOfficeDebitor.agent --> role:hsOfficeBankAccount.tenant - - role:hsOfficeDebitor.tenant[debitor.tenant] - %% incoming - role:hsOfficeDebitor.agent ---> role:hsOfficeDebitor.tenant - role:hsOfficePartner.agent --> role:hsOfficeDebitor.tenant - role:hsOfficeBankAccount.admin --> role:hsOfficeDebitor.tenant - %% outgoing - role:hsOfficeDebitor.tenant --> role:hsOfficePartner.tenant - role:hsOfficeDebitor.tenant --> role:hsOfficeContact.guest - - role:hsOfficeDebitor.guest[debitor.guest] - %% permissions - role:hsOfficeDebitor.guest --> perm:hsOfficeDebitor.view{{debitor.view}} - %% incoming - role:hsOfficeDebitor.tenant --> role:hsOfficeDebitor.guest - end - -end - -subgraph hsOfficeSepaMandate - - role:hsOfficeSepaMandate.owner[sepaMandate.owner] - %% permissions - role:hsOfficeSepaMandate.owner --> perm:hsOfficeSepaMandate.*{{sepaMandate.*}} - %% incoming - role:global.admin ---> role:hsOfficeSepaMandate.owner - - role:hsOfficeSepaMandate.admin[sepaMandate.admin] - %% permissions - role:hsOfficeSepaMandate.admin --> perm:hsOfficeSepaMandate.edit{{sepaMandate.edit}} - %% incoming - role:hsOfficeSepaMandate.owner ---> role:hsOfficeSepaMandate.admin - - role:hsOfficeSepaMandate.agent[sepaMandate.agent] - %% incoming - role:hsOfficeSepaMandate.admin ---> role:hsOfficeSepaMandate.agent - role:hsOfficeDebitor.admin --> role:hsOfficeSepaMandate.agent - role:hsOfficeBankAccount.admin --> role:hsOfficeSepaMandate.agent - %% outgoing - role:hsOfficeSepaMandate.agent --> role:hsOfficeDebitor.tenant - role:hsOfficeSepaMandate.admin --> role:hsOfficeBankAccount.tenant - - role:hsOfficeSepaMandate.tenant[sepaMandate.tenant] - %% incoming - role:hsOfficeSepaMandate.agent --> role:hsOfficeSepaMandate.tenant - %% outgoing - role:hsOfficeSepaMandate.tenant --> role:hsOfficeDebitor.guest - role:hsOfficeSepaMandate.tenant --> role:hsOfficeBankAccount.guest - - role:hsOfficeSepaMandate.guest[sepaMandate.guest] - %% permissions - role:hsOfficeSepaMandate.guest --> perm:hsOfficeSepaMandate.view{{sepaMandate.view}} - %% incoming - role:hsOfficeSepaMandate.tenant --> role:hsOfficeSepaMandate.guest -end - -subgraph hosting - style hosting fill:#eee - - subgraph package - style package fill: #e9f7ef - - role:package.owner[package.owner] - --> role:package.admin[package.admin] - --> role:package.tenant[package.tenant] - - role:hsOfficeDebitor.agent --> role:package.owner - role:package.admin --> role:hsOfficeDebitor.tenant - role:hsOfficePartner.tenant --> role:hsOfficeDebitor.guest - end -end - - -``` - diff --git a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql deleted file mode 100644 index e6572e55..00000000 --- a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql +++ /dev/null @@ -1,245 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-debitor-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_debitor'); ---// - - --- ============================================================================ ---changeset hs-office-debitor-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeDebitor', 'hs_office_debitor'); ---// - - --- ============================================================================ ---changeset hs-office-debitor-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the roles and their assignments for debitor entities. - */ - -create or replace function hsOfficeDebitorRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - hsOfficeDebitorTenant RbacRoleDescriptor; - oldPartner hs_office_partner; - newPartner hs_office_partner; - newPerson hs_office_person; - oldContact hs_office_contact; - newContact hs_office_contact; - newBankAccount hs_office_bankaccount; - oldBankAccount hs_office_bankaccount; -begin - - hsOfficeDebitorTenant := hsOfficeDebitorTenant(NEW); - - select * from hs_office_partner as p where p.uuid = NEW.partnerUuid into newPartner; - select * from hs_office_person as p where p.uuid = newPartner.personUuid into newPerson; - select * from hs_office_contact as c where c.uuid = NEW.billingContactUuid into newContact; - select * from hs_office_bankaccount as b where b.uuid = NEW.refundBankAccountUuid into newBankAccount; - if TG_OP = 'INSERT' then - - - perform createRoleWithGrants( - hsOfficeDebitorOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()], - userUuids => array[currentUserUuid()], - grantedByRole => globalAdmin() - ); - - perform createRoleWithGrants( - hsOfficeDebitorAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficeDebitorOwner(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeDebitorAgent(NEW), - incomingSuperRoles => array[ - hsOfficeDebitorAdmin(NEW), - hsOfficePartnerAdmin(newPartner), - hsOfficeContactAdmin(newContact)], - outgoingSubRoles => array[ - hsOfficeBankAccountTenant(newBankaccount)] - ); - - perform createRoleWithGrants( - hsOfficeDebitorTenant(NEW), - incomingSuperRoles => array[ - hsOfficeDebitorAgent(NEW), - hsOfficePartnerAgent(newPartner), - hsOfficeBankAccountAdmin(newBankaccount)], - outgoingSubRoles => array[ - hsOfficePartnerTenant(newPartner), - hsOfficeContactGuest(newContact), - hsOfficeBankAccountGuest(newBankaccount)] - ); - - perform createRoleWithGrants( - hsOfficeDebitorGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[ - hsOfficeDebitorTenant(NEW)] - ); - - elsif TG_OP = 'UPDATE' then - - if OLD.partnerUuid <> NEW.partnerUuid then - select * from hs_office_partner as p where p.uuid = OLD.partnerUuid into oldPartner; - - call revokeRoleFromRole(hsOfficeDebitorAgent(OLD), hsOfficePartnerAdmin(oldPartner)); - call grantRoleToRole(hsOfficeDebitorAgent(NEW), hsOfficePartnerAdmin(newPartner)); - - call revokeRoleFromRole(hsOfficeDebitorTenant(OLD), hsOfficePartnerAgent(oldPartner)); - call grantRoleToRole(hsOfficeDebitorTenant(NEW), hsOfficePartnerAgent(newPartner)); - - call revokeRoleFromRole(hsOfficePartnerTenant(oldPartner), hsOfficeDebitorTenant(OLD)); - call grantRoleToRole(hsOfficePartnerTenant(newPartner), hsOfficeDebitorTenant(NEW)); - end if; - - if OLD.billingContactUuid <> NEW.billingContactUuid then - select * from hs_office_contact as c where c.uuid = OLD.billingContactUuid into oldContact; - - call revokeRoleFromRole(hsOfficeDebitorAgent(OLD), hsOfficeContactAdmin(oldContact)); - call grantRoleToRole(hsOfficeDebitorAgent(NEW), hsOfficeContactAdmin(newContact)); - - call revokeRoleFromRole(hsOfficeContactGuest(oldContact), hsOfficeDebitorTenant(OLD)); - call grantRoleToRole(hsOfficeContactGuest(newContact), hsOfficeDebitorTenant(NEW)); - end if; - - if (OLD.refundBankAccountUuid is not null or NEW.refundBankAccountUuid is not null) and - ( OLD.refundBankAccountUuid is null or NEW.refundBankAccountUuid is null or - OLD.refundBankAccountUuid <> NEW.refundBankAccountUuid ) then - - select * from hs_office_bankaccount as b where b.uuid = OLD.refundBankAccountUuid into oldBankAccount; - - if oldBankAccount is not null then - call revokeRoleFromRole(hsOfficeBankAccountTenant(oldBankaccount), hsOfficeDebitorAgent(OLD)); - end if; - if newBankAccount is not null then - call grantRoleToRole(hsOfficeBankAccountTenant(newBankaccount), hsOfficeDebitorAgent(NEW)); - end if; - - if oldBankAccount is not null then - call revokeRoleFromRole(hsOfficeDebitorTenant(OLD), hsOfficeBankAccountAdmin(oldBankaccount)); - end if; - if newBankAccount is not null then - call grantRoleToRole(hsOfficeDebitorTenant(NEW), hsOfficeBankAccountAdmin(newBankaccount)); - end if; - - if oldBankAccount is not null then - call revokeRoleFromRole(hsOfficeBankAccountGuest(oldBankaccount), hsOfficeDebitorTenant(OLD)); - end if; - if newBankAccount is not null then - call grantRoleToRole(hsOfficeBankAccountGuest(newBankaccount), hsOfficeDebitorTenant(NEW)); - end if; - end if; - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new debitor. - */ -create trigger createRbacRolesForHsOfficeDebitor_Trigger - after insert - on hs_office_debitor - for each row -execute procedure hsOfficeDebitorRbacRolesTrigger(); - -/* - An AFTER UPDATE TRIGGER which updates the role structure of a debitor. - */ -create trigger updateRbacRolesForHsOfficeDebitor_Trigger - after update - on hs_office_debitor - for each row -execute procedure hsOfficeDebitorRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-debitor-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_debitor', $idName$ - '#' || - (select partnerNumber from hs_office_partner p where p.uuid = target.partnerUuid) || - to_char(debitorNumberSuffix, 'fm00') || - ':' || (select split_part(idName, ':', 2) from hs_office_partner_iv pi where pi.uuid = target.partnerUuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-debitor-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_debitor', 'target.debitorNumberSuffix', - $updates$ - partnerUuid = new.partnerUuid, -- TODO: remove? should never do anything - billable = new.billable, - billingContactUuid = new.billingContactUuid, - debitorNumberSuffix = new.debitorNumberSuffix, -- TODO: Should it be allowed to updated this value? - refundBankAccountUuid = new.refundBankAccountUuid, - vatId = new.vatId, - vatCountryCode = new.vatCountryCode, - vatBusiness = new.vatBusiness, - vatreversecharge = new.vatreversecharge, - defaultPrefix = new.defaultPrefix -- TODO: Should it be allowed to updated this value? - $updates$); ---// - --- ============================================================================ ---changeset hs-office-debitor-rbac-NEW-DEBITOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-debitor and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addDebitorPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-debitor permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addDebitorPermissions := createPermissions(globalObjectUuid, array ['new-debitor']); - call grantPermissionsToRole(globalAdminRoleUuid, addDebitorPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-debitor to current user respectively assumed roles. - */ -create or replace function addHsOfficeDebitorNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-debitor not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new debitor. - */ -create trigger hs_office_debitor_insert_trigger - before insert - on hs_office_debitor - for each row - -- TODO.spec: who is allowed to create new debitors - when ( not hasAssumedRole() ) -execute procedure addHsOfficeDebitorNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql b/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql deleted file mode 100644 index af75d074..00000000 --- a/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql +++ /dev/null @@ -1,57 +0,0 @@ ---liquibase formatted sql - - --- ============================================================================ ---changeset hs-office-debitor-TEST-DATA-GENERATOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a single debitor test record. - */ -create or replace procedure createHsOfficeDebitorTestData( - debitorNumberSuffix numeric(5), - partnerTradeName varchar, - billingContactLabel varchar, - defaultPrefix varchar - ) - language plpgsql as $$ -declare - currentTask varchar; - idName varchar; - relatedPartner hs_office_partner; - relatedContact hs_office_contact; - relatedBankAccountUuid uuid; -begin - idName := cleanIdentifier( partnerTradeName|| '-' || billingContactLabel); - currentTask := 'creating debitor test-data ' || idName; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - - select partner.* from hs_office_partner partner - join hs_office_person person on person.uuid = partner.personUuid - where person.tradeName = partnerTradeName into relatedPartner; - select c.* from hs_office_contact c where c.label = billingContactLabel into relatedContact; - select b.uuid from hs_office_bankaccount b where b.holder = partnerTradeName into relatedBankAccountUuid; - - raise notice 'creating test debitor: % (#%)', idName, debitorNumberSuffix; - raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; - raise notice '- using billingContact (%): %', relatedContact.uuid, relatedContact; - insert - into hs_office_debitor (uuid, partneruuid, debitornumbersuffix, billable, billingcontactuuid, vatbusiness, vatreversecharge, refundbankaccountuuid, defaultprefix) - values (uuid_generate_v4(), relatedPartner.uuid, debitorNumberSuffix, true, relatedContact.uuid, true, false, relatedBankAccountUuid, defaultPrefix); -end; $$; ---// - - --- ============================================================================ ---changeset hs-office-debitor-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// --- ---------------------------------------------------------------------------- - -do language plpgsql $$ - begin - call createHsOfficeDebitorTestData(11, 'First GmbH', 'first contact', 'fir'); - call createHsOfficeDebitorTestData(12, 'Second e.K.', 'second contact', 'sec'); - call createHsOfficeDebitorTestData(13, 'Third OHG', 'third contact', 'thi'); - end; -$$; ---// diff --git a/src/main/resources/db/changelog/303-hs-office-membership-rbac.md b/src/main/resources/db/changelog/303-hs-office-membership-rbac.md deleted file mode 100644 index 8cf604ab..00000000 --- a/src/main/resources/db/changelog/303-hs-office-membership-rbac.md +++ /dev/null @@ -1,75 +0,0 @@ -### hs_office_membership RBAC - -```mermaid -flowchart TB - -subgraph global - style global fill:#eee - - role:global.admin[global.admin] -end - -subgraph hsOfficeDebitor - direction TB - style hsOfficeDebitor fill:#eee - - role:hsOfficeDebitor.owner[debitor.owner] - --> role:hsOfficeDebitor.admin[debitor.admin] - --> role:hsOfficeDebitor.tenant[debitor.tenant] - --> role:hsOfficeDebitor.guest[debitor.guest] -end - -subgraph hsOfficePartner - direction TB - style hsOfficePartner fill:#eee - - role:hsOfficePartner.owner[partner.admin] - --> role:hsOfficePartner.admin[partner.admin] - --> role:hsOfficePartner.agent[partner.agent] - --> role:hsOfficePartner.tenant[partner.tenant] - --> role:hsOfficePartner.guest[partner.guest] -end - -subgraph hsOfficeMembership - - role:hsOfficeMembership.owner[membership.owner] - %% permissions - role:hsOfficeMembership.owner --> perm:hsOfficeMembership.*{{membership.*}} - %% incoming - role:global.admin ---> role:hsOfficeMembership.owner - - role:hsOfficeMembership.admin[membership.admin] - %% permissions - role:hsOfficeMembership.admin --> perm:hsOfficeMembership.edit{{membership.edit}} - %% incoming - role:hsOfficeMembership.owner ---> role:hsOfficeMembership.admin - - role:hsOfficeMembership.agent[membership.agent] - %% incoming - role:hsOfficeMembership.admin ---> role:hsOfficeMembership.agent - role:hsOfficePartner.admin --> role:hsOfficeMembership.agent - role:hsOfficeDebitor.admin --> role:hsOfficeMembership.agent - %% outgoing - role:hsOfficeMembership.agent --> role:hsOfficePartner.tenant - role:hsOfficeMembership.agent --> role:hsOfficeDebitor.tenant - - role:hsOfficeMembership.tenant[membership.tenant] - %% incoming - role:hsOfficeMembership.agent --> role:hsOfficeMembership.tenant - role:hsOfficePartner.agent --> role:hsOfficeMembership.tenant - role:hsOfficeDebitor.agent --> role:hsOfficeMembership.tenant - %% outgoing - role:hsOfficeMembership.tenant --> role:hsOfficePartner.guest - role:hsOfficeMembership.tenant --> role:hsOfficeDebitor.guest - - role:hsOfficeMembership.guest[membership.guest] - %% permissions - role:hsOfficeMembership.guest --> perm:hsOfficeMembership.view{{membership.view}} - %% incoming - role:hsOfficeMembership.tenant --> role:hsOfficeMembership.guest - role:hsOfficePartner.tenant --> role:hsOfficeMembership.guest - role:hsOfficeDebitor.tenant --> role:hsOfficeMembership.guest -end - - -``` diff --git a/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql b/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql deleted file mode 100644 index 8197cf09..00000000 --- a/src/main/resources/db/changelog/303-hs-office-membership-rbac.sql +++ /dev/null @@ -1,160 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-membership-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_membership'); ---// - - --- ============================================================================ ---changeset hs-office-membership-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeMembership', 'hs_office_membership'); ---// - - --- ============================================================================ ---changeset hs-office-membership-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the roles and their assignments for membership entities. - */ - -create or replace function hsOfficeMembershipRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - newHsOfficePartner hs_office_partner; - newHsOfficeDebitor hs_office_debitor; -begin - - select * from hs_office_partner as p where p.uuid = NEW.partnerUuid into newHsOfficePartner; - select * from hs_office_debitor as c where c.uuid = NEW.mainDebitorUuid into newHsOfficeDebitor; - - if TG_OP = 'INSERT' then - - -- === ATTENTION: code generated from related Mermaid flowchart: === - - perform createRoleWithGrants( - hsOfficeMembershipOwner(NEW), - permissions => array['*'], - incomingSuperRoles => array[globalAdmin()] - ); - - perform createRoleWithGrants( - hsOfficeMembershipAdmin(NEW), - permissions => array['edit'], - incomingSuperRoles => array[hsOfficeMembershipOwner(NEW)] - ); - - perform createRoleWithGrants( - hsOfficeMembershipAgent(NEW), - incomingSuperRoles => array[hsOfficeMembershipAdmin(NEW), hsOfficePartnerAdmin(newHsOfficePartner), hsOfficeDebitorAdmin(newHsOfficeDebitor)], - outgoingSubRoles => array[hsOfficePartnerTenant(newHsOfficePartner), hsOfficeDebitorTenant(newHsOfficeDebitor)] - ); - - perform createRoleWithGrants( - hsOfficeMembershipTenant(NEW), - incomingSuperRoles => array[hsOfficeMembershipAgent(NEW), hsOfficePartnerAgent(newHsOfficePartner), hsOfficeDebitorAgent(newHsOfficeDebitor)], - outgoingSubRoles => array[hsOfficePartnerGuest(newHsOfficePartner), hsOfficeDebitorGuest(newHsOfficeDebitor)] - ); - - perform createRoleWithGrants( - hsOfficeMembershipGuest(NEW), - permissions => array['view'], - incomingSuperRoles => array[hsOfficeMembershipTenant(NEW), hsOfficePartnerTenant(newHsOfficePartner), hsOfficeDebitorTenant(newHsOfficeDebitor)] - ); - - -- === END of code generated from Mermaid flowchart. === - - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeMembership_Trigger - after insert - on hs_office_membership - for each row -execute procedure hsOfficeMembershipRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-membership-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_membership', idNameExpression => $idName$ - '#' || - (select partnerNumber from hs_office_partner p where p.uuid = target.partnerUuid) || - memberNumberSuffix || - ':' || (select split_part(idName, ':', 2) from hs_office_partner_iv p where p.uuid = target.partnerUuid) - $idName$); ---// - - --- ============================================================================ ---changeset hs-office-membership-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_membership', - orderby => 'target.memberNumberSuffix', - columnUpdates => $updates$ - validity = new.validity, - reasonForTermination = new.reasonForTermination, - membershipFeeBillable = new.membershipFeeBillable - $updates$); ---// - - --- ============================================================================ ---changeset hs-office-membership-rbac-NEW-Membership:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-membership and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-membership permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-membership']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeMembershipNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-membership not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_membership_insert_trigger - before insert - on hs_office_membership - for each row - -- TODO.spec: who is allowed to create new memberships - when ( not hasAssumedRole() ) -execute procedure addHsOfficeMembershipNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql b/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql deleted file mode 100644 index 637c87ca..00000000 --- a/src/main/resources/db/changelog/308-hs-office-membership-test-data.sql +++ /dev/null @@ -1,56 +0,0 @@ ---liquibase formatted sql - - --- ============================================================================ ---changeset hs-office-membership-TEST-DATA-GENERATOR:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates a single membership test record. - */ -create or replace procedure createHsOfficeMembershipTestData( - forPartnerTradeName varchar, - forMainDebitorNumberSuffix numeric, - newMemberNumberSuffix char(2) ) - language plpgsql as $$ -declare - currentTask varchar; - idName varchar; - relatedPartner hs_office_partner; - relatedDebitor hs_office_debitor; -begin - idName := cleanIdentifier( forPartnerTradeName || '#' || forMainDebitorNumberSuffix); - currentTask := 'creating Membership test-data ' || idName; - call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); - execute format('set local hsadminng.currentTask to %L', currentTask); - - select partner.* from hs_office_partner partner - join hs_office_person person on person.uuid = partner.personUuid - where person.tradeName = forPartnerTradeName into relatedPartner; - select d.* from hs_office_debitor d - where d.partneruuid = relatedPartner.uuid - and d.debitorNumberSuffix = forMainDebitorNumberSuffix - into relatedDebitor; - - raise notice 'creating test Membership: %', idName; - raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; - raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; - insert - into hs_office_membership (uuid, partneruuid, maindebitoruuid, memberNumberSuffix, validity, reasonfortermination) - values (uuid_generate_v4(), relatedPartner.uuid, relatedDebitor.uuid, newMemberNumberSuffix, daterange('20221001' , null, '[]'), 'NONE'); -end; $$; ---// - - --- ============================================================================ ---changeset hs-office-membership-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// --- ---------------------------------------------------------------------------- - -do language plpgsql $$ - begin - call createHsOfficeMembershipTestData('First GmbH', 11, '01'); - call createHsOfficeMembershipTestData('Second e.K.', 12, '02'); - call createHsOfficeMembershipTestData('Third OHG', 13, '03'); - end; -$$; ---// diff --git a/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.md b/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.md deleted file mode 100644 index 4093eb2d..00000000 --- a/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.md +++ /dev/null @@ -1,29 +0,0 @@ -### hs_office_coopSharesTransaction RBAC - -```mermaid -flowchart TB - -subgraph hsOfficeMembership - direction TB - style hsOfficeMembership fill:#eee - - role:hsOfficeMembership.owner[membership.admin] - --> role:hsOfficeMembership.admin[membership.admin] - --> role:hsOfficeMembership.agent[membership.agent] - --> role:hsOfficeMembership.tenant[membership.tenant] - --> role:hsOfficeMembership.guest[membership.guest] - - role:hsOfficePartner.agent --> role:hsOfficeMembership.agent -end - -subgraph hsOfficeCoopSharesTransaction - - role:hsOfficeMembership.admin - --> perm:hsOfficeCoopSharesTransaction.create{{coopSharesTx.create}} - - role:hsOfficeMembership.agent - --> perm:hsOfficeCoopSharesTransaction.view{{coopSharesTx.view}} -end - - -``` diff --git a/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.sql b/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.sql deleted file mode 100644 index d6afcfc8..00000000 --- a/src/main/resources/db/changelog/313-hs-office-coopshares-rbac.sql +++ /dev/null @@ -1,124 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_coopSharesTransaction'); ---// - - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeCoopSharesTransaction', 'hs_office_coopSharesTransaction'); ---// - - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the permissions for coopSharesTransaction entities. - */ - -create or replace function hsOfficeCoopSharesTransactionRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - newHsOfficeMembership hs_office_membership; -begin - - select * from hs_office_membership as p where p.uuid = NEW.membershipUuid into newHsOfficeMembership; - - if TG_OP = 'INSERT' then - - -- Each coopSharesTransaction entity belong exactly to one membership entity - -- and it makes little sense just to delegate coopSharesTransaction roles. - -- Therefore, we do not create coopSharesTransaction roles at all, - -- but instead just assign extra permissions to existing membership-roles. - - -- coopsharestransactions cannot be edited nor deleted, just created+viewed - call grantPermissionsToRole( - getRoleId(hsOfficeMembershipTenant(newHsOfficeMembership), 'fail'), - createPermissions(NEW.uuid, array ['view']) - ); - - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeCoopSharesTransaction_Trigger - after insert - on hs_office_coopSharesTransaction - for each row -execute procedure hsOfficeCoopSharesTransactionRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_coopSharesTransaction', - idNameExpression => 'target.reference'); ---// - - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_coopSharesTransaction', orderby => 'target.reference'); ---// - - --- ============================================================================ ---changeset hs-office-coopSharesTransaction-rbac-NEW-CoopSharesTransaction:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-coopSharesTransaction and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-coopSharesTransaction permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-coopsharestransaction']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeCoopSharesTransactionNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-coopsharestransaction not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_coopSharesTransaction_insert_trigger - before insert - on hs_office_coopSharesTransaction - for each row - when ( not hasAssumedRole() ) -execute procedure addHsOfficeCoopSharesTransactionNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md deleted file mode 100644 index 94ce746a..00000000 --- a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md +++ /dev/null @@ -1,29 +0,0 @@ -### hs_office_coopAssetsTransaction RBAC - -```mermaid -flowchart TB - -subgraph hsOfficeMembership - direction TB - style hsOfficeMembership fill:#eee - - role:hsOfficeMembership.owner[membership.admin] - --> role:hsOfficeMembership.admin[membership.admin] - --> role:hsOfficeMembership.agent[membership.agent] - --> role:hsOfficeMembership.tenant[membership.tenant] - --> role:hsOfficeMembership.guest[membership.guest] - - role:hsOfficePartner.agent --> role:hsOfficeMembership.agent -end - -subgraph hsOfficeCoopAssetsTransaction - - role:hsOfficeMembership.admin - --> perm:hsOfficeCoopAssetsTransaction.create{{coopAssetsTx.create}} - - role:hsOfficeMembership.agent - --> perm:hsOfficeCoopAssetsTransaction.view{{coopAssetsTx.view}} -end - - -``` diff --git a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql deleted file mode 100644 index 6589eaa2..00000000 --- a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql +++ /dev/null @@ -1,124 +0,0 @@ ---liquibase formatted sql - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-OBJECT:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_office_coopAssetsTransaction'); ---// - - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsOfficeCoopAssetsTransaction', 'hs_office_coopAssetsTransaction'); ---// - - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-ROLES-CREATION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/* - Creates and updates the permissions for coopAssetsTransaction entities. - */ - -create or replace function hsOfficeCoopAssetsTransactionRbacRolesTrigger() - returns trigger - language plpgsql - strict as $$ -declare - newHsOfficeMembership hs_office_membership; -begin - - select * from hs_office_membership as p where p.uuid = NEW.membershipUuid into newHsOfficeMembership; - - if TG_OP = 'INSERT' then - - -- Each coopAssetsTransaction entity belong exactly to one membership entity - -- and it makes little sense just to delegate coopAssetsTransaction roles. - -- Therefore, we do not create coopAssetsTransaction roles at all, - -- but instead just assign extra permissions to existing membership-roles. - - -- coopassetstransactions cannot be edited nor deleted, just created+viewed - call grantPermissionsToRole( - getRoleId(hsOfficeMembershipTenant(newHsOfficeMembership), 'fail'), - createPermissions(NEW.uuid, array ['view']) - ); - - else - raise exception 'invalid usage of TRIGGER'; - end if; - - return NEW; -end; $$; - -/* - An AFTER INSERT TRIGGER which creates the role structure for a new customer. - */ -create trigger createRbacRolesForHsOfficeCoopAssetsTransaction_Trigger - after insert - on hs_office_coopAssetsTransaction - for each row -execute procedure hsOfficeCoopAssetsTransactionRbacRolesTrigger(); ---// - - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-IDENTITY-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacIdentityView('hs_office_coopAssetsTransaction', - idNameExpression => 'target.reference'); ---// - - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-RESTRICTED-VIEW:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_office_coopAssetsTransaction', orderby => 'target.reference'); ---// - - --- ============================================================================ ---changeset hs-office-coopAssetsTransaction-rbac-NEW-CoopAssetsTransaction:1 endDelimiter:--// --- ---------------------------------------------------------------------------- -/* - Creates a global permission for new-coopAssetsTransaction and assigns it to the hostsharing admins role. - */ -do language plpgsql $$ - declare - addCustomerPermissions uuid[]; - globalObjectUuid uuid; - globalAdminRoleUuid uuid ; - begin - call defineContext('granting global new-coopAssetsTransaction permission to global admin role', null, null, null); - - globalAdminRoleUuid := findRoleId(globalAdmin()); - globalObjectUuid := (select uuid from global); - addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-coopassetstransaction']); - call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); - end; -$$; - -/** - Used by the trigger to prevent the add-customer to current user respectively assumed roles. - */ -create or replace function addHsOfficeCoopAssetsTransactionNotAllowedForCurrentSubjects() - returns trigger - language PLPGSQL -as $$ -begin - raise exception '[403] new-coopassetstransaction not permitted for %', - array_to_string(currentSubjects(), ';', 'null'); -end; $$; - -/** - Checks if the user or assumed roles are allowed to create a new customer. - */ -create trigger hs_office_coopAssetsTransaction_insert_trigger - before insert - on hs_office_coopAssetsTransaction - for each row - when ( not hasAssumedRole() ) -execute procedure addHsOfficeCoopAssetsTransactionNotAllowedForCurrentSubjects(); ---// - diff --git a/src/main/resources/db/changelog/200-hs-office-contact.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql similarity index 81% rename from src/main/resources/db/changelog/200-hs-office-contact.sql rename to src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql index 9b67db1b..514f2ca0 100644 --- a/src/main/resources/db/changelog/200-hs-office-contact.sql +++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql @@ -7,10 +7,11 @@ create table if not exists hs_office_contact ( uuid uuid unique references RbacObject (uuid) initially deferred, - label varchar(128) not null, + version int not null default 0, + caption varchar(128) not null, postalAddress text, - emailAddresses text, -- TODO.feat: change to json - phoneNumbers text -- TODO.feat: change to json + emailAddresses jsonb not null, + phoneNumbers jsonb not null ); --// diff --git a/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.md b/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.md new file mode 100644 index 00000000..fe736072 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.md @@ -0,0 +1,45 @@ +### rbac contact + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph contact["`**contact**`"] + direction TB + style contact fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph contact:roles[ ] + style contact:roles fill:#dd4901,stroke:white + + role:contact:OWNER[[contact:OWNER]] + role:contact:ADMIN[[contact:ADMIN]] + role:contact:REFERRER[[contact:REFERRER]] + end + + subgraph contact:permissions[ ] + style contact:permissions fill:#dd4901,stroke:white + + perm:contact:DELETE{{contact:DELETE}} + perm:contact:UPDATE{{contact:UPDATE}} + perm:contact:SELECT{{contact:SELECT}} + perm:contact:INSERT{{contact:INSERT}} + end +end + +%% granting roles to users +user:creator ==> role:contact:OWNER + +%% granting roles to roles +role:global:ADMIN ==> role:contact:OWNER +role:contact:OWNER ==> role:contact:ADMIN +role:contact:ADMIN ==> role:contact:REFERRER + +%% granting permissions to roles +role:contact:OWNER ==> perm:contact:DELETE +role:contact:ADMIN ==> perm:contact:UPDATE +role:contact:REFERRER ==> perm:contact:SELECT +role:global:GUEST ==> perm:contact:INSERT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.sql new file mode 100644 index 00000000..d1fabf3e --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5013-hs-office-contact-rbac.sql @@ -0,0 +1,104 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-contact-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_contact'); +--// + + +-- ============================================================================ +--changeset hs-office-contact-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeContact', 'hs_office_contact'); +--// + + +-- ============================================================================ +--changeset hs-office-contact-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeContact( + NEW hs_office_contact +) + language plpgsql as $$ + +declare + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + perform createRoleWithGrants( + hsOfficeContactOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN()], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficeContactADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsOfficeContactOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsOfficeContactREFERRER(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsOfficeContactADMIN(NEW)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_contact row. + */ + +create or replace function insertTriggerForHsOfficeContact_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeContact(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeContact_tg + after insert on hs_office_contact + for each row +execute procedure insertTriggerForHsOfficeContact_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-contact-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_contact', + $idName$ + caption + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-contact-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_contact', + $orderBy$ + caption + $orderBy$, + $updates$ + caption = new.caption, + postalAddress = new.postalAddress, + emailAddresses = new.emailAddresses, + phoneNumbers = new.phoneNumbers + $updates$); +--// + diff --git a/src/main/resources/db/changelog/206-hs-office-contact-migration.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5016-hs-office-contact-migration.sql similarity index 100% rename from src/main/resources/db/changelog/206-hs-office-contact-migration.sql rename to src/main/resources/db/changelog/5-hs-office/501-contact/5016-hs-office-contact-migration.sql diff --git a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql similarity index 70% rename from src/main/resources/db/changelog/208-hs-office-contact-test-data.sql rename to src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql index af1fc304..fbee80ad 100644 --- a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql @@ -8,28 +8,28 @@ /* Creates a single contact test record. */ -create or replace procedure createHsOfficeContactTestData(contLabel varchar) +create or replace procedure createHsOfficeContactTestData(contCaption varchar) language plpgsql as $$ declare - currentTask varchar; - emailAddr varchar; + postalAddr varchar; + emailAddr varchar; begin - currentTask = 'creating contact test-data ' || contLabel; - execute format('set local hsadminng.currentTask to %L', currentTask); - - emailAddr = 'contact-admin@' || cleanIdentifier(contLabel) || '.example.com'; - call defineContext(currentTask); + emailAddr = 'contact-admin@' || cleanIdentifier(contCaption) || '.example.com'; + call defineContext('creating contact test-data'); perform createRbacUser(emailAddr); - call defineContext(currentTask, null, emailAddr); + call defineContext('creating contact test-data', null, emailAddr); - raise notice 'creating test contact: %', contLabel; + postalAddr := E'Vorname Nachname\nStraße Hnr\nPLZ Stadt'; + + raise notice 'creating test contact: %', contCaption; insert - into hs_office_contact (label, postaladdress, emailaddresses, phonenumbers) - values (contLabel, $address$ -Vorname Nachname -Straße Hnr -PLZ Stadt -$address$, emailAddr, '+49 123 1234567'); + into hs_office_contact (caption, postaladdress, emailaddresses, phonenumbers) + values ( + contCaption, + postalAddr, + ('{ "main": "' || emailAddr || '" }')::jsonb, + ('{ "phone_office": "+49 123 1234567" }')::jsonb + ); end; $$; --// @@ -61,7 +61,7 @@ do language plpgsql $$ call createHsOfficeContactTestData('first contact'); call createHsOfficeContactTestData('second contact'); call createHsOfficeContactTestData('third contact'); - call createHsOfficeContactTestData('forth contact'); + call createHsOfficeContactTestData('fourth contact'); call createHsOfficeContactTestData('fifth contact'); call createHsOfficeContactTestData('sixth contact'); call createHsOfficeContactTestData('seventh contact'); diff --git a/src/main/resources/db/changelog/210-hs-office-person.sql b/src/main/resources/db/changelog/5-hs-office/502-person/5020-hs-office-person.sql similarity index 91% rename from src/main/resources/db/changelog/210-hs-office-person.sql rename to src/main/resources/db/changelog/5-hs-office/502-person/5020-hs-office-person.sql index 6a331277..528b512c 100644 --- a/src/main/resources/db/changelog/210-hs-office-person.sql +++ b/src/main/resources/db/changelog/5-hs-office/502-person/5020-hs-office-person.sql @@ -17,12 +17,14 @@ CREATE CAST (character varying as HsOfficePersonType) WITH INOUT AS IMPLICIT; create table if not exists hs_office_person ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, personType HsOfficePersonType not null, tradeName varchar(96), + salutation varchar(30), + title varchar(20), givenName varchar(48), familyName varchar(48) ); ---// -- ============================================================================ diff --git a/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.md b/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.md new file mode 100644 index 00000000..d0eebfdd --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.md @@ -0,0 +1,45 @@ +### rbac person + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph person["`**person**`"] + direction TB + style person fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph person:roles[ ] + style person:roles fill:#dd4901,stroke:white + + role:person:OWNER[[person:OWNER]] + role:person:ADMIN[[person:ADMIN]] + role:person:REFERRER[[person:REFERRER]] + end + + subgraph person:permissions[ ] + style person:permissions fill:#dd4901,stroke:white + + perm:person:INSERT{{person:INSERT}} + perm:person:DELETE{{person:DELETE}} + perm:person:UPDATE{{person:UPDATE}} + perm:person:SELECT{{person:SELECT}} + end +end + +%% granting roles to users +user:creator ==> role:person:OWNER + +%% granting roles to roles +role:global:ADMIN ==> role:person:OWNER +role:person:OWNER ==> role:person:ADMIN +role:person:ADMIN ==> role:person:REFERRER + +%% granting permissions to roles +role:global:GUEST ==> perm:person:INSERT +role:person:OWNER ==> perm:person:DELETE +role:person:ADMIN ==> perm:person:UPDATE +role:person:REFERRER ==> perm:person:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.sql b/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.sql new file mode 100644 index 00000000..bdaca63c --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/502-person/5023-hs-office-person-rbac.sql @@ -0,0 +1,106 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-person-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_person'); +--// + + +-- ============================================================================ +--changeset hs-office-person-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficePerson', 'hs_office_person'); +--// + + +-- ============================================================================ +--changeset hs-office-person-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficePerson( + NEW hs_office_person +) + language plpgsql as $$ + +declare + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + perform createRoleWithGrants( + hsOfficePersonOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN()], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficePersonADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsOfficePersonOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsOfficePersonREFERRER(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsOfficePersonADMIN(NEW)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_person row. + */ + +create or replace function insertTriggerForHsOfficePerson_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficePerson(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficePerson_tg + after insert on hs_office_person + for each row +execute procedure insertTriggerForHsOfficePerson_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-person-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_person', + $idName$ + concat(tradeName, familyName, givenName) + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-person-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_person', + $orderBy$ + concat(tradeName, familyName, givenName) + $orderBy$, + $updates$ + personType = new.personType, + title = new.title, + salutation = new.salutation, + tradeName = new.tradeName, + givenName = new.givenName, + familyName = new.familyName + $updates$); +--// + diff --git a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql b/src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql similarity index 75% rename from src/main/resources/db/changelog/218-hs-office-person-test-data.sql rename to src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql index 09d51b1a..8900886c 100644 --- a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql @@ -17,18 +17,15 @@ create or replace procedure createHsOfficePersonTestData( language plpgsql as $$ declare fullName varchar; - currentTask varchar; emailAddr varchar; begin fullName := concat_ws(', ', newTradeName, newFamilyName, newGivenName); - currentTask = 'creating person test-data ' || fullName; emailAddr = 'person-' || left(cleanIdentifier(fullName), 32) || '@example.com'; - call defineContext(currentTask); + call defineContext('creating person test-data'); perform createRbacUser(emailAddr); - call defineContext(currentTask, null, emailAddr); - execute format('set local hsadminng.currentTask to %L', currentTask); + call defineContext('creating person test-data', null, emailAddr); - raise notice 'creating test person: %', fullName; + raise notice 'creating test person: % by %', fullName, emailAddr; insert into hs_office_person (persontype, tradename, givenname, familyname) values (newPersonType, newTradeName, newGivenName, newFamilyName); @@ -46,7 +43,7 @@ create or replace procedure createTestPersonTestData( begin for t in startCount..endCount loop - call createHsOfficePersonTestData('LEGAL', intToVarChar(t, 4)); + call createHsOfficePersonTestData('LP', intToVarChar(t, 4)); commit; end loop; end; $$; @@ -59,13 +56,18 @@ end; $$; do language plpgsql $$ begin + call createHsOfficePersonTestData('LP', 'Hostsharing eG'); call createHsOfficePersonTestData('LP', 'First GmbH'); + call createHsOfficePersonTestData('NP', null, 'Firby', 'Susan'); call createHsOfficePersonTestData('NP', null, 'Smith', 'Peter'); - call createHsOfficePersonTestData('LP', 'Second e.K.', 'Sandra', 'Miller'); + call createHsOfficePersonTestData('NP', null, 'Tucker', 'Jack'); + call createHsOfficePersonTestData('NP', null, 'Fouler', 'Ellie'); + call createHsOfficePersonTestData('LP', 'Second e.K.', 'Smith', 'Peter'); call createHsOfficePersonTestData('IF', 'Third OHG'); - call createHsOfficePersonTestData('IF', 'Fourth e.G.'); + call createHsOfficePersonTestData('LP', 'Fourth eG'); call createHsOfficePersonTestData('UF', 'Erben Bessler', 'Mel', 'Bessler'); call createHsOfficePersonTestData('NP', null, 'Bessler', 'Anita'); + call createHsOfficePersonTestData('NP', null, 'Bessler', 'Bert'); call createHsOfficePersonTestData('NP', null, 'Winkler', 'Paul'); end; $$; diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql b/src/main/resources/db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql new file mode 100644 index 00000000..1c207177 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql @@ -0,0 +1,37 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-relation-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE TYPE HsOfficeRelationType AS ENUM ( + 'UNKNOWN', + 'PARTNER', + 'EX_PARTNER', + 'REPRESENTATIVE', + 'DEBITOR', + 'VIP_CONTACT', + 'OPERATIONS', + 'SUBSCRIBER'); + +CREATE CAST (character varying as HsOfficeRelationType) WITH INOUT AS IMPLICIT; + +create table if not exists hs_office_relation +( + uuid uuid unique references RbacObject (uuid) initially deferred, -- on delete cascade + version int not null default 0, + anchorUuid uuid not null references hs_office_person(uuid), + holderUuid uuid not null references hs_office_person(uuid), + contactUuid uuid references hs_office_contact(uuid), + type HsOfficeRelationType not null, + mark varchar(24) +); +--// + + +-- ============================================================================ +--changeset hs-office-relation-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_office_relation'); +--// diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac-REPRESENTATIVE.md b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac-REPRESENTATIVE.md new file mode 100644 index 00000000..0d944401 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac-REPRESENTATIVE.md @@ -0,0 +1,102 @@ +### rbac relation inCaseOf:REPRESENTATIVE + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph anchorPerson["`**anchorPerson**`"] + direction TB + style anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph anchorPerson:roles[ ] + style anchorPerson:roles fill:#99bcdb,stroke:white + + role:anchorPerson:OWNER[[anchorPerson:OWNER]] + role:anchorPerson:ADMIN[[anchorPerson:ADMIN]] + role:anchorPerson:REFERRER[[anchorPerson:REFERRER]] + end +end + +subgraph contact["`**contact**`"] + direction TB + style contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph contact:roles[ ] + style contact:roles fill:#99bcdb,stroke:white + + role:contact:OWNER[[contact:OWNER]] + role:contact:ADMIN[[contact:ADMIN]] + role:contact:REFERRER[[contact:REFERRER]] + end +end + +subgraph holderPerson["`**holderPerson**`"] + direction TB + style holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph holderPerson:roles[ ] + style holderPerson:roles fill:#99bcdb,stroke:white + + role:holderPerson:OWNER[[holderPerson:OWNER]] + role:holderPerson:ADMIN[[holderPerson:ADMIN]] + role:holderPerson:REFERRER[[holderPerson:REFERRER]] + end +end + +subgraph relation["`**relation**`"] + direction TB + style relation fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph relation:roles[ ] + style relation:roles fill:#dd4901,stroke:white + + role:relation:OWNER[[relation:OWNER]] + role:relation:ADMIN[[relation:ADMIN]] + role:relation:AGENT[[relation:AGENT]] + role:relation:TENANT[[relation:TENANT]] + end + + subgraph relation:permissions[ ] + style relation:permissions fill:#dd4901,stroke:white + + perm:relation:DELETE{{relation:DELETE}} + perm:relation:UPDATE{{relation:UPDATE}} + perm:relation:SELECT{{relation:SELECT}} + perm:relation:INSERT{{relation:INSERT}} + end +end + +%% granting roles to users +user:creator ==> role:relation:OWNER + +%% granting roles to roles +role:global:ADMIN -.-> role:anchorPerson:OWNER +role:anchorPerson:OWNER -.-> role:anchorPerson:ADMIN +role:anchorPerson:ADMIN -.-> role:anchorPerson:REFERRER +role:global:ADMIN -.-> role:holderPerson:OWNER +role:holderPerson:OWNER -.-> role:holderPerson:ADMIN +role:holderPerson:ADMIN -.-> role:holderPerson:REFERRER +role:global:ADMIN -.-> role:contact:OWNER +role:contact:OWNER -.-> role:contact:ADMIN +role:contact:ADMIN -.-> role:contact:REFERRER +role:global:ADMIN ==> role:relation:OWNER +role:holderPerson:ADMIN ==> role:relation:OWNER +role:relation:OWNER ==> role:relation:ADMIN +role:relation:ADMIN ==> role:anchorPerson:OWNER +role:relation:ADMIN ==> role:relation:AGENT +role:anchorPerson:ADMIN ==> role:relation:AGENT +role:relation:AGENT ==> role:relation:TENANT +role:contact:ADMIN ==> role:relation:TENANT +role:relation:TENANT ==> role:anchorPerson:REFERRER +role:relation:TENANT ==> role:holderPerson:REFERRER +role:relation:TENANT ==> role:contact:REFERRER + +%% granting permissions to roles +role:relation:OWNER ==> perm:relation:DELETE +role:relation:ADMIN ==> perm:relation:UPDATE +role:relation:TENANT ==> perm:relation:SELECT +role:anchorPerson:ADMIN ==> perm:relation:INSERT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.md b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.md new file mode 100644 index 00000000..47d4d220 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.md @@ -0,0 +1,101 @@ +### rbac relation inOtherCases + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph anchorPerson["`**anchorPerson**`"] + direction TB + style anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph anchorPerson:roles[ ] + style anchorPerson:roles fill:#99bcdb,stroke:white + + role:anchorPerson:OWNER[[anchorPerson:OWNER]] + role:anchorPerson:ADMIN[[anchorPerson:ADMIN]] + role:anchorPerson:REFERRER[[anchorPerson:REFERRER]] + end +end + +subgraph contact["`**contact**`"] + direction TB + style contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph contact:roles[ ] + style contact:roles fill:#99bcdb,stroke:white + + role:contact:OWNER[[contact:OWNER]] + role:contact:ADMIN[[contact:ADMIN]] + role:contact:REFERRER[[contact:REFERRER]] + end +end + +subgraph holderPerson["`**holderPerson**`"] + direction TB + style holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph holderPerson:roles[ ] + style holderPerson:roles fill:#99bcdb,stroke:white + + role:holderPerson:OWNER[[holderPerson:OWNER]] + role:holderPerson:ADMIN[[holderPerson:ADMIN]] + role:holderPerson:REFERRER[[holderPerson:REFERRER]] + end +end + +subgraph relation["`**relation**`"] + direction TB + style relation fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph relation:roles[ ] + style relation:roles fill:#dd4901,stroke:white + + role:relation:OWNER[[relation:OWNER]] + role:relation:ADMIN[[relation:ADMIN]] + role:relation:AGENT[[relation:AGENT]] + role:relation:TENANT[[relation:TENANT]] + end + + subgraph relation:permissions[ ] + style relation:permissions fill:#dd4901,stroke:white + + perm:relation:DELETE{{relation:DELETE}} + perm:relation:UPDATE{{relation:UPDATE}} + perm:relation:SELECT{{relation:SELECT}} + perm:relation:INSERT{{relation:INSERT}} + end +end + +%% granting roles to users +user:creator ==> role:relation:OWNER + +%% granting roles to roles +role:global:ADMIN -.-> role:anchorPerson:OWNER +role:anchorPerson:OWNER -.-> role:anchorPerson:ADMIN +role:anchorPerson:ADMIN -.-> role:anchorPerson:REFERRER +role:global:ADMIN -.-> role:holderPerson:OWNER +role:holderPerson:OWNER -.-> role:holderPerson:ADMIN +role:holderPerson:ADMIN -.-> role:holderPerson:REFERRER +role:global:ADMIN -.-> role:contact:OWNER +role:contact:OWNER -.-> role:contact:ADMIN +role:contact:ADMIN -.-> role:contact:REFERRER +role:global:ADMIN ==> role:relation:OWNER +role:relation:OWNER ==> role:relation:ADMIN +role:relation:ADMIN ==> role:relation:AGENT +role:relation:AGENT ==> role:relation:TENANT +role:contact:ADMIN ==> role:relation:TENANT +role:relation:TENANT ==> role:anchorPerson:REFERRER +role:relation:TENANT ==> role:holderPerson:REFERRER +role:relation:TENANT ==> role:contact:REFERRER +role:anchorPerson:ADMIN ==> role:relation:OWNER +role:holderPerson:ADMIN ==> role:relation:AGENT + +%% granting permissions to roles +role:relation:OWNER ==> perm:relation:DELETE +role:relation:ADMIN ==> perm:relation:UPDATE +role:relation:TENANT ==> perm:relation:SELECT +role:anchorPerson:ADMIN ==> perm:relation:INSERT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql new file mode 100644 index 00000000..63c2061a --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql @@ -0,0 +1,254 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-relation-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_relation'); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeRelation', 'hs_office_relation'); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeRelation( + NEW hs_office_relation +) + language plpgsql as $$ + +declare + newHolderPerson hs_office_person; + newAnchorPerson hs_office_person; + newContact hs_office_contact; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_person WHERE uuid = NEW.holderUuid INTO newHolderPerson; + assert newHolderPerson.uuid is not null, format('newHolderPerson must not be null for NEW.holderUuid = %s', NEW.holderUuid); + + SELECT * FROM hs_office_person WHERE uuid = NEW.anchorUuid INTO newAnchorPerson; + assert newAnchorPerson.uuid is not null, format('newAnchorPerson must not be null for NEW.anchorUuid = %s', NEW.anchorUuid); + + SELECT * FROM hs_office_contact WHERE uuid = NEW.contactUuid INTO newContact; + assert newContact.uuid is not null, format('newContact must not be null for NEW.contactUuid = %s', NEW.contactUuid); + + + perform createRoleWithGrants( + hsOfficeRelationOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN()], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficeRelationADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsOfficeRelationOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsOfficeRelationAGENT(NEW), + incomingSuperRoles => array[hsOfficeRelationADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsOfficeRelationTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[ + hsOfficeContactADMIN(newContact), + hsOfficeRelationAGENT(NEW)], + outgoingSubRoles => array[ + hsOfficeContactREFERRER(newContact), + hsOfficePersonREFERRER(newAnchorPerson), + hsOfficePersonREFERRER(newHolderPerson)] + ); + + IF NEW.type = 'REPRESENTATIVE' THEN + call grantRoleToRole(hsOfficePersonOWNER(newAnchorPerson), hsOfficeRelationADMIN(NEW)); + call grantRoleToRole(hsOfficeRelationAGENT(NEW), hsOfficePersonADMIN(newAnchorPerson)); + call grantRoleToRole(hsOfficeRelationOWNER(NEW), hsOfficePersonADMIN(newHolderPerson)); + ELSE + call grantRoleToRole(hsOfficeRelationAGENT(NEW), hsOfficePersonADMIN(newHolderPerson)); + call grantRoleToRole(hsOfficeRelationOWNER(NEW), hsOfficePersonADMIN(newAnchorPerson)); + END IF; + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_relation row. + */ + +create or replace function insertTriggerForHsOfficeRelation_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeRelation(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeRelation_tg + after insert on hs_office_relation + for each row +execute procedure insertTriggerForHsOfficeRelation_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForHsOfficeRelation( + OLD hs_office_relation, + NEW hs_office_relation +) + language plpgsql as $$ +begin + + if NEW.contactUuid is distinct from OLD.contactUuid then + delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid; + call buildRbacSystemForHsOfficeRelation(NEW); + end if; +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_office_relation row. + */ + +create or replace function updateTriggerForHsOfficeRelation_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForHsOfficeRelation(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForHsOfficeRelation_tg + after update on hs_office_relation + for each row +execute procedure updateTriggerForHsOfficeRelation_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to hs_office_person ---------------------------- + +/* + Grants INSERT INTO hs_office_relation permissions to specified role of pre-existing hs_office_person rows. + */ +do language plpgsql $$ + declare + row hs_office_person; + begin + call defineContext('create INSERT INTO hs_office_relation permissions for pre-exising hs_office_person rows'); + + FOR row IN SELECT * FROM hs_office_person + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_relation'), + hsOfficePersonADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_office_relation INSERT permission to specified role of new hs_office_person rows. +*/ +create or replace function new_hs_office_relation_grants_insert_to_hs_office_person_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_relation'), + hsOfficePersonADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_relation_grants_insert_to_hs_office_person_tg + after insert on hs_office_person + for each row +execute procedure new_hs_office_relation_grants_insert_to_hs_office_person_tf(); + + +-- ============================================================================ +--changeset hs_office_relation-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_relation. +*/ +create or replace function hs_office_relation_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via direct foreign key: NEW.anchorUuid + if hasInsertPermission(NEW.anchorUuid, 'hs_office_relation') then + return NEW; + end if; + + raise exception '[403] insert into hs_office_relation not allowed for current subjects % (%)', + currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_relation_insert_permission_check_tg + before insert on hs_office_relation + for each row + execute procedure hs_office_relation_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_relation', + $idName$ + (select idName from hs_office_person_iv p where p.uuid = anchorUuid) + || '-with-' || target.type || '-' + || (select idName from hs_office_person_iv p where p.uuid = holderUuid) + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-relation-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_relation', + $orderBy$ + (select idName from hs_office_person_iv p where p.uuid = target.holderUuid) + $orderBy$, + $updates$ + contactUuid = new.contactUuid + $updates$); +--// + diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5038-hs-office-relation-test-data.sql b/src/main/resources/db/changelog/5-hs-office/503-relation/5038-hs-office-relation-test-data.sql new file mode 100644 index 00000000..120ffe62 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5038-hs-office-relation-test-data.sql @@ -0,0 +1,111 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-relation-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single relation test record. + */ +create or replace procedure createHsOfficeRelationTestData( + holderPersonName varchar, + relationType HsOfficeRelationType, + anchorPersonName varchar, + contactCaption varchar, + mark varchar default null) + language plpgsql as $$ +declare + idName varchar; + anchorPerson hs_office_person; + holderPerson hs_office_person; + contact hs_office_contact; + +begin + idName := cleanIdentifier( anchorPersonName || '-' || holderPersonName); + + select p.* + into anchorPerson + from hs_office_person p + where p.tradeName = anchorPersonName or p.familyName = anchorPersonName; + if anchorPerson is null then + raise exception 'anchorPerson "%" not found', anchorPersonName; + end if; + + select p.* + into holderPerson + from hs_office_person p + where p.tradeName = holderPersonName or p.familyName = holderPersonName; + if holderPerson is null then + raise exception 'holderPerson "%" not found', holderPersonName; + end if; + + select c.* into contact from hs_office_contact c where c.caption = contactCaption; + if contact is null then + raise exception 'contact "%" not found', contactCaption; + end if; + + raise notice 'creating test relation: %', idName; + raise notice '- using anchor person (%): %', anchorPerson.uuid, anchorPerson; + raise notice '- using holder person (%): %', holderPerson.uuid, holderPerson; + raise notice '- using contact (%): %', contact.uuid, contact; + insert + into hs_office_relation (uuid, anchoruuid, holderuuid, type, mark, contactUuid) + values (uuid_generate_v4(), anchorPerson.uuid, holderPerson.uuid, relationType, mark, contact.uuid); +end; $$; +--// + +/* + Creates a range of test relation for mass data generation. + */ +create or replace procedure createHsOfficeRelationTestData( + startCount integer, -- count of auto generated rows before the run + endCount integer -- count of auto generated rows after the run +) + language plpgsql as $$ +declare + person hs_office_person; + contact hs_office_contact; +begin + for t in startCount..endCount + loop + select p.* from hs_office_person p where tradeName = intToVarChar(t, 4) into person; + select c.* from hs_office_contact c where c.caption = intToVarChar(t, 4) || '#' || t into contact; + + call createHsOfficeRelationTestData(person.uuid, contact.uuid, 'REPRESENTATIVE'); + commit; + end loop; +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-relation-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating relation test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsOfficeRelationTestData('First GmbH', 'PARTNER', 'Hostsharing eG', 'first contact'); + call createHsOfficeRelationTestData('Firby', 'REPRESENTATIVE', 'First GmbH', 'first contact'); + call createHsOfficeRelationTestData('First GmbH', 'DEBITOR', 'First GmbH', 'first contact'); + + call createHsOfficeRelationTestData('Second e.K.', 'PARTNER', 'Hostsharing eG', 'second contact'); + call createHsOfficeRelationTestData('Smith', 'REPRESENTATIVE', 'Second e.K.', 'second contact'); + call createHsOfficeRelationTestData('Second e.K.', 'DEBITOR', 'Second e.K.', 'second contact'); + + call createHsOfficeRelationTestData('Third OHG', 'PARTNER', 'Hostsharing eG', 'third contact'); + call createHsOfficeRelationTestData('Tucker', 'REPRESENTATIVE', 'Third OHG', 'third contact'); + call createHsOfficeRelationTestData('Third OHG', 'DEBITOR', 'Third OHG', 'third contact'); + + call createHsOfficeRelationTestData('Fourth eG', 'PARTNER', 'Hostsharing eG', 'fourth contact'); + call createHsOfficeRelationTestData('Fouler', 'REPRESENTATIVE', 'Third OHG', 'third contact'); + call createHsOfficeRelationTestData('Third OHG', 'DEBITOR', 'Third OHG', 'third contact'); + + call createHsOfficeRelationTestData('Smith', 'PARTNER', 'Hostsharing eG', 'sixth contact'); + call createHsOfficeRelationTestData('Smith', 'DEBITOR', 'Smith', 'third contact'); + call createHsOfficeRelationTestData('Smith', 'SUBSCRIBER', 'Third OHG', 'third contact', 'members-announce'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/220-hs-office-partner.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5040-hs-office-partner.sql similarity index 50% rename from src/main/resources/db/changelog/220-hs-office-partner.sql rename to src/main/resources/db/changelog/5-hs-office/504-partner/5040-hs-office-partner.sql index c4491b0a..a8a88adc 100644 --- a/src/main/resources/db/changelog/220-hs-office-partner.sql +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5040-hs-office-partner.sql @@ -8,6 +8,7 @@ create table hs_office_partner_details ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, registrationOffice varchar(96), registrationNumber varchar(96), birthPlace varchar(96), @@ -32,14 +33,52 @@ call create_journal('hs_office_partner_details'); create table hs_office_partner ( uuid uuid unique references RbacObject (uuid) initially deferred, - partnerNumber numeric(5), - personUuid uuid not null references hs_office_person(uuid), - contactUuid uuid not null references hs_office_contact(uuid), - detailsUuid uuid not null references hs_office_partner_details(uuid) on delete cascade + version int not null default 0, + partnerNumber numeric(5) unique not null, + partnerRelUuid uuid not null references hs_office_relation(uuid), -- deleted in after delete trigger + detailsUuid uuid not null references hs_office_partner_details(uuid) -- deleted in after delete trigger ); --// +-- ============================================================================ +--changeset hs-office-partner-DELETE-DEPENDENTS-TRIGGER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Trigger function to delete related details of a partner to delete. + */ +create or replace function deleteHsOfficeDependentsOnPartnerDelete() + returns trigger + language PLPGSQL +as $$ +declare + counter integer; +begin + DELETE FROM hs_office_partner_details d WHERE d.uuid = OLD.detailsUuid; + GET DIAGNOSTICS counter = ROW_COUNT; + if counter = 0 then + raise exception 'partner details % could not be deleted', OLD.detailsUuid; + end if; + + DELETE FROM hs_office_relation r WHERE r.uuid = OLD.partnerRelUuid; + GET DIAGNOSTICS counter = ROW_COUNT; + if counter = 0 then + raise exception 'partner relation % could not be deleted', OLD.partnerRelUuid; + end if; + + RETURN OLD; +end; $$; + +/** + Triggers deletion of related rows of a partner to delete. + */ +create trigger hs_office_partner_delete_dependents_trigger + after delete + on hs_office_partner + for each row + execute procedure deleteHsOfficeDependentsOnPartnerDelete(); + -- ============================================================================ --changeset hs-office-partner-MAIN-TABLE-JOURNAL:1 endDelimiter:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.md b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.md new file mode 100644 index 00000000..ecbe29de --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.md @@ -0,0 +1,119 @@ +### rbac partner + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph partner["`**partner**`"] + direction TB + style partner fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph partner:permissions[ ] + style partner:permissions fill:#dd4901,stroke:white + + perm:partner:INSERT{{partner:INSERT}} + perm:partner:DELETE{{partner:DELETE}} + perm:partner:UPDATE{{partner:UPDATE}} + perm:partner:SELECT{{partner:SELECT}} + end + + subgraph partnerRel["`**partnerRel**`"] + direction TB + style partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel:roles[ ] + style partnerRel:roles fill:#99bcdb,stroke:white + + role:partnerRel:OWNER[[partnerRel:OWNER]] + role:partnerRel:ADMIN[[partnerRel:ADMIN]] + role:partnerRel:AGENT[[partnerRel:AGENT]] + role:partnerRel:TENANT[[partnerRel:TENANT]] + end + end +end + +subgraph partnerDetails["`**partnerDetails**`"] + direction TB + style partnerDetails fill:#feb28c,stroke:#274d6e,stroke-width:8px + + subgraph partnerDetails:permissions[ ] + style partnerDetails:permissions fill:#feb28c,stroke:white + + perm:partnerDetails:DELETE{{partnerDetails:DELETE}} + perm:partnerDetails:UPDATE{{partnerDetails:UPDATE}} + perm:partnerDetails:SELECT{{partnerDetails:SELECT}} + end +end + +subgraph partnerRel.anchorPerson["`**partnerRel.anchorPerson**`"] + direction TB + style partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.anchorPerson:roles[ ] + style partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.anchorPerson:OWNER[[partnerRel.anchorPerson:OWNER]] + role:partnerRel.anchorPerson:ADMIN[[partnerRel.anchorPerson:ADMIN]] + role:partnerRel.anchorPerson:REFERRER[[partnerRel.anchorPerson:REFERRER]] + end +end + +subgraph partnerRel.contact["`**partnerRel.contact**`"] + direction TB + style partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.contact:roles[ ] + style partnerRel.contact:roles fill:#99bcdb,stroke:white + + role:partnerRel.contact:OWNER[[partnerRel.contact:OWNER]] + role:partnerRel.contact:ADMIN[[partnerRel.contact:ADMIN]] + role:partnerRel.contact:REFERRER[[partnerRel.contact:REFERRER]] + end +end + +subgraph partnerRel.holderPerson["`**partnerRel.holderPerson**`"] + direction TB + style partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.holderPerson:roles[ ] + style partnerRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.holderPerson:OWNER[[partnerRel.holderPerson:OWNER]] + role:partnerRel.holderPerson:ADMIN[[partnerRel.holderPerson:ADMIN]] + role:partnerRel.holderPerson:REFERRER[[partnerRel.holderPerson:REFERRER]] + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:partnerRel.anchorPerson:OWNER +role:partnerRel.anchorPerson:OWNER -.-> role:partnerRel.anchorPerson:ADMIN +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.holderPerson:OWNER +role:partnerRel.holderPerson:OWNER -.-> role:partnerRel.holderPerson:ADMIN +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.contact:OWNER +role:partnerRel.contact:OWNER -.-> role:partnerRel.contact:ADMIN +role:partnerRel.contact:ADMIN -.-> role:partnerRel.contact:REFERRER +role:global:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel:OWNER -.-> role:partnerRel:ADMIN +role:partnerRel:ADMIN -.-> role:partnerRel:AGENT +role:partnerRel:AGENT -.-> role:partnerRel:TENANT +role:partnerRel.contact:ADMIN -.-> role:partnerRel:TENANT +role:partnerRel:TENANT -.-> role:partnerRel.anchorPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.holderPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.contact:REFERRER +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel:AGENT + +%% granting permissions to roles +role:global:ADMIN ==> perm:partner:INSERT +role:partnerRel:OWNER ==> perm:partner:DELETE +role:partnerRel:ADMIN ==> perm:partner:UPDATE +role:partnerRel:TENANT ==> perm:partner:SELECT +role:partnerRel:OWNER ==> perm:partnerDetails:DELETE +role:partnerRel:AGENT ==> perm:partnerDetails:UPDATE +role:partnerRel:AGENT ==> perm:partnerDetails:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql new file mode 100644 index 00000000..bd1c673d --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql @@ -0,0 +1,255 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-partner-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_partner'); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficePartner', 'hs_office_partner'); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficePartner( + NEW hs_office_partner +) + language plpgsql as $$ + +declare + newPartnerRel hs_office_relation; + newPartnerDetails hs_office_partner_details; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_relation WHERE uuid = NEW.partnerRelUuid INTO newPartnerRel; + assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s', NEW.partnerRelUuid); + + SELECT * FROM hs_office_partner_details WHERE uuid = NEW.detailsUuid INTO newPartnerDetails; + assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s', NEW.detailsUuid); + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), hsOfficeRelationOWNER(newPartnerRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'SELECT'), hsOfficeRelationTENANT(newPartnerRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'UPDATE'), hsOfficeRelationADMIN(newPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'DELETE'), hsOfficeRelationOWNER(newPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'SELECT'), hsOfficeRelationAGENT(newPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'UPDATE'), hsOfficeRelationAGENT(newPartnerRel)); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_partner row. + */ + +create or replace function insertTriggerForHsOfficePartner_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficePartner(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficePartner_tg + after insert on hs_office_partner + for each row +execute procedure insertTriggerForHsOfficePartner_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForHsOfficePartner( + OLD hs_office_partner, + NEW hs_office_partner +) + language plpgsql as $$ + +declare + oldPartnerRel hs_office_relation; + newPartnerRel hs_office_relation; + oldPartnerDetails hs_office_partner_details; + newPartnerDetails hs_office_partner_details; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_relation WHERE uuid = OLD.partnerRelUuid INTO oldPartnerRel; + assert oldPartnerRel.uuid is not null, format('oldPartnerRel must not be null for OLD.partnerRelUuid = %s', OLD.partnerRelUuid); + + SELECT * FROM hs_office_relation WHERE uuid = NEW.partnerRelUuid INTO newPartnerRel; + assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s', NEW.partnerRelUuid); + + SELECT * FROM hs_office_partner_details WHERE uuid = OLD.detailsUuid INTO oldPartnerDetails; + assert oldPartnerDetails.uuid is not null, format('oldPartnerDetails must not be null for OLD.detailsUuid = %s', OLD.detailsUuid); + + SELECT * FROM hs_office_partner_details WHERE uuid = NEW.detailsUuid INTO newPartnerDetails; + assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s', NEW.detailsUuid); + + + if NEW.partnerRelUuid <> OLD.partnerRelUuid then + + call revokePermissionFromRole(getPermissionId(OLD.uuid, 'DELETE'), hsOfficeRelationOWNER(oldPartnerRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), hsOfficeRelationOWNER(newPartnerRel)); + + call revokePermissionFromRole(getPermissionId(OLD.uuid, 'UPDATE'), hsOfficeRelationADMIN(oldPartnerRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'UPDATE'), hsOfficeRelationADMIN(newPartnerRel)); + + call revokePermissionFromRole(getPermissionId(OLD.uuid, 'SELECT'), hsOfficeRelationTENANT(oldPartnerRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'SELECT'), hsOfficeRelationTENANT(newPartnerRel)); + + call revokePermissionFromRole(getPermissionId(oldPartnerDetails.uuid, 'DELETE'), hsOfficeRelationOWNER(oldPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'DELETE'), hsOfficeRelationOWNER(newPartnerRel)); + + call revokePermissionFromRole(getPermissionId(oldPartnerDetails.uuid, 'UPDATE'), hsOfficeRelationAGENT(oldPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'UPDATE'), hsOfficeRelationAGENT(newPartnerRel)); + + call revokePermissionFromRole(getPermissionId(oldPartnerDetails.uuid, 'SELECT'), hsOfficeRelationAGENT(oldPartnerRel)); + call grantPermissionToRole(createPermission(newPartnerDetails.uuid, 'SELECT'), hsOfficeRelationAGENT(newPartnerRel)); + + end if; + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_office_partner row. + */ + +create or replace function updateTriggerForHsOfficePartner_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForHsOfficePartner(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForHsOfficePartner_tg + after update on hs_office_partner + for each row +execute procedure updateTriggerForHsOfficePartner_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_office_partner permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_office_partner permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_partner'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_office_partner INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_office_partner_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_partner'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_partner_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_office_partner_grants_insert_to_global_tf(); + + +-- ============================================================================ +--changeset hs_office_partner-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_partner. +*/ +create or replace function hs_office_partner_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + + raise exception '[403] insert into hs_office_partner values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_partner_insert_permission_check_tg + before insert on hs_office_partner + for each row + execute procedure hs_office_partner_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_partner', + $idName$ + 'P-' || partnerNumber + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-partner-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_partner', + $orderBy$ + 'P-' || partnerNumber + $orderBy$, + $updates$ + partnerRelUuid = new.partnerRelUuid + $updates$); +--// + diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.md b/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.md new file mode 100644 index 00000000..347896bb --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.md @@ -0,0 +1,23 @@ +### rbac partnerDetails + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph partnerDetails["`**partnerDetails**`"] + direction TB + style partnerDetails fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph partnerDetails:permissions[ ] + style partnerDetails:permissions fill:#dd4901,stroke:white + + perm:partnerDetails:INSERT{{partnerDetails:INSERT}} + end +end + +%% granting permissions to roles +role:global:ADMIN ==> perm:partnerDetails:INSERT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.sql new file mode 100644 index 00000000..8a7f2725 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5044-hs-office-partner-details-rbac.sql @@ -0,0 +1,167 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_partner_details'); +--// + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficePartnerDetails', 'hs_office_partner_details'); +--// + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficePartnerDetails( + NEW hs_office_partner_details +) + language plpgsql as $$ + +declare + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_partner_details row. + */ + +create or replace function insertTriggerForHsOfficePartnerDetails_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficePartnerDetails(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficePartnerDetails_tg + after insert on hs_office_partner_details + for each row +execute procedure insertTriggerForHsOfficePartnerDetails_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_office_partner_details permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_office_partner_details permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_partner_details'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_office_partner_details INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_office_partner_details_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_partner_details'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_partner_details_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_office_partner_details_grants_insert_to_global_tf(); + + +-- ============================================================================ +--changeset hs_office_partner_details-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_partner_details. +*/ +create or replace function hs_office_partner_details_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + + raise exception '[403] insert into hs_office_partner_details values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_partner_details_insert_permission_check_tg + before insert on hs_office_partner_details + for each row + execute procedure hs_office_partner_details_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromQuery('hs_office_partner_details', + $idName$ + SELECT partnerDetails.uuid as uuid, partner_iv.idName as idName + FROM hs_office_partner_details AS partnerDetails + JOIN hs_office_partner partner ON partner.detailsUuid = partnerDetails.uuid + JOIN hs_office_partner_iv partner_iv ON partner_iv.uuid = partner.uuid + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-partner-details-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_partner_details', + $orderBy$ + uuid + $orderBy$, + $updates$ + registrationOffice = new.registrationOffice, + registrationNumber = new.registrationNumber, + birthPlace = new.birthPlace, + birthName = new.birthName, + birthday = new.birthday, + dateOfDeath = new.dateOfDeath + $updates$); +--// + diff --git a/src/main/resources/db/changelog/226-hs-office-partner-migration.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5046-hs-office-partner-migration.sql similarity index 100% rename from src/main/resources/db/changelog/226-hs-office-partner-migration.sql rename to src/main/resources/db/changelog/5-hs-office/504-partner/5046-hs-office-partner-migration.sql diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5048-hs-office-partner-test-data.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5048-hs-office-partner-test-data.sql new file mode 100644 index 00000000..4ac1dff9 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5048-hs-office-partner-test-data.sql @@ -0,0 +1,83 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-partner-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single partner test record. + */ +create or replace procedure createHsOfficePartnerTestData( + mandantTradeName varchar, + newPartnerNumber numeric(5), + partnerPersonName varchar, + contactCaption varchar ) + language plpgsql as $$ +declare + idName varchar; + mandantPerson hs_office_person; + partnerRel hs_office_relation; + relatedPerson hs_office_person; + relatedDetailsUuid uuid; +begin + idName := cleanIdentifier( partnerPersonName|| '-' || contactCaption); + + select p.* from hs_office_person p + where p.tradeName = mandantTradeName + into mandantPerson; + if mandantPerson is null then + raise exception 'mandant "%" not found', mandantTradeName; + end if; + + select p.* from hs_office_person p + where p.tradeName = partnerPersonName or p.familyName = partnerPersonName + into relatedPerson; + + select r.* from hs_office_relation r + where r.type = 'PARTNER' + and r.anchoruuid = mandantPerson.uuid and r.holderuuid = relatedPerson.uuid + into partnerRel; + if partnerRel is null then + raise exception 'partnerRel "%"-"%" not found', mandantPerson.tradename, partnerPersonName; + end if; + + raise notice 'creating test partner: %', idName; + raise notice '- using partnerRel (%): %', partnerRel.uuid, partnerRel; + raise notice '- using person (%): %', relatedPerson.uuid, relatedPerson; + + if relatedPerson.persontype = 'NP' then + insert + into hs_office_partner_details (uuid, birthName, birthday, birthPlace) + values (uuid_generate_v4(), 'Meyer', '1987-10-31', 'Hamburg') + returning uuid into relatedDetailsUuid; + else + insert + into hs_office_partner_details (uuid, registrationOffice, registrationNumber) + values (uuid_generate_v4(), 'Hamburg', 'RegNo123456789') + returning uuid into relatedDetailsUuid; + end if; + + insert + into hs_office_partner (uuid, partnerNumber, partnerRelUuid, detailsUuid) + values (uuid_generate_v4(), newPartnerNumber, partnerRel.uuid, relatedDetailsUuid); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-partner-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating partner test-data ', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsOfficePartnerTestData('Hostsharing eG', 10001, 'First GmbH', 'first contact'); + call createHsOfficePartnerTestData('Hostsharing eG', 10002, 'Second e.K.', 'second contact'); + call createHsOfficePartnerTestData('Hostsharing eG', 10003, 'Third OHG', 'third contact'); + call createHsOfficePartnerTestData('Hostsharing eG', 10004, 'Fourth eG', 'fourth contact'); + call createHsOfficePartnerTestData('Hostsharing eG', 10010, 'Smith', 'fifth contact'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/240-hs-office-bankaccount.sql b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5050-hs-office-bankaccount.sql similarity index 94% rename from src/main/resources/db/changelog/240-hs-office-bankaccount.sql rename to src/main/resources/db/changelog/5-hs-office/505-bankaccount/5050-hs-office-bankaccount.sql index 427b0199..e061a3ca 100644 --- a/src/main/resources/db/changelog/240-hs-office-bankaccount.sql +++ b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5050-hs-office-bankaccount.sql @@ -6,6 +6,7 @@ create table hs_office_bankaccount ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, holder varchar(64) not null, iban varchar(34) not null, bic varchar(11) not null diff --git a/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.md b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.md new file mode 100644 index 00000000..4558815c --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.md @@ -0,0 +1,45 @@ +### rbac bankAccount + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph bankAccount["`**bankAccount**`"] + direction TB + style bankAccount fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph bankAccount:roles[ ] + style bankAccount:roles fill:#dd4901,stroke:white + + role:bankAccount:OWNER[[bankAccount:OWNER]] + role:bankAccount:ADMIN[[bankAccount:ADMIN]] + role:bankAccount:REFERRER[[bankAccount:REFERRER]] + end + + subgraph bankAccount:permissions[ ] + style bankAccount:permissions fill:#dd4901,stroke:white + + perm:bankAccount:INSERT{{bankAccount:INSERT}} + perm:bankAccount:DELETE{{bankAccount:DELETE}} + perm:bankAccount:UPDATE{{bankAccount:UPDATE}} + perm:bankAccount:SELECT{{bankAccount:SELECT}} + end +end + +%% granting roles to users +user:creator ==> role:bankAccount:OWNER + +%% granting roles to roles +role:global:ADMIN ==> role:bankAccount:OWNER +role:bankAccount:OWNER ==> role:bankAccount:ADMIN +role:bankAccount:ADMIN ==> role:bankAccount:REFERRER + +%% granting permissions to roles +role:global:GUEST ==> perm:bankAccount:INSERT +role:bankAccount:OWNER ==> perm:bankAccount:DELETE +role:bankAccount:ADMIN ==> perm:bankAccount:UPDATE +role:bankAccount:REFERRER ==> perm:bankAccount:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.sql b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.sql new file mode 100644 index 00000000..724dd658 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac.sql @@ -0,0 +1,103 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-bankaccount-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_bankaccount'); +--// + + +-- ============================================================================ +--changeset hs-office-bankaccount-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeBankAccount', 'hs_office_bankaccount'); +--// + + +-- ============================================================================ +--changeset hs-office-bankaccount-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeBankAccount( + NEW hs_office_bankaccount +) + language plpgsql as $$ + +declare + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + perform createRoleWithGrants( + hsOfficeBankAccountOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN()], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficeBankAccountADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsOfficeBankAccountOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsOfficeBankAccountREFERRER(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsOfficeBankAccountADMIN(NEW)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_bankaccount row. + */ + +create or replace function insertTriggerForHsOfficeBankAccount_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeBankAccount(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeBankAccount_tg + after insert on hs_office_bankaccount + for each row +execute procedure insertTriggerForHsOfficeBankAccount_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-bankaccount-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_bankaccount', + $idName$ + iban + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-bankaccount-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_bankaccount', + $orderBy$ + iban + $orderBy$, + $updates$ + holder = new.holder, + iban = new.iban, + bic = new.bic + $updates$); +--// + diff --git a/src/main/resources/db/changelog/248-hs-office-bankaccount-test-data.sql b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5058-hs-office-bankaccount-test-data.sql similarity index 84% rename from src/main/resources/db/changelog/248-hs-office-bankaccount-test-data.sql rename to src/main/resources/db/changelog/5-hs-office/505-bankaccount/5058-hs-office-bankaccount-test-data.sql index 88deb9fe..338ab61c 100644 --- a/src/main/resources/db/changelog/248-hs-office-bankaccount-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/505-bankaccount/5058-hs-office-bankaccount-test-data.sql @@ -11,16 +11,11 @@ create or replace procedure createHsOfficeBankAccountTestData(givenHolder varchar, givenIBAN varchar, givenBIC varchar) language plpgsql as $$ declare - currentTask varchar; emailAddr varchar; begin - currentTask = 'creating bankaccount test-data ' || givenHolder; - execute format('set local hsadminng.currentTask to %L', currentTask); - emailAddr = 'bankaccount-admin@' || cleanIdentifier(givenHolder) || '.example.com'; - call defineContext(currentTask); perform createRbacUser(emailAddr); - call defineContext(currentTask, null, emailAddr); + call defineContext('creating bankaccount test-data', null, emailAddr); raise notice 'creating test bankaccount: %', givenHolder; insert @@ -36,12 +31,14 @@ end; $$; do language plpgsql $$ begin + call defineContext('creating bankaccount test-data'); + -- IBANs+BICs taken from https://ibanvalidieren.de/beispiele.html call createHsOfficeBankAccountTestData('First GmbH', 'DE02120300000000202051', 'BYLADEM1001'); call createHsOfficeBankAccountTestData('Peter Smith', 'DE02500105170137075030', 'INGDDEFF'); call createHsOfficeBankAccountTestData('Second e.K.', 'DE02100500000054540402', 'BELADEBE'); call createHsOfficeBankAccountTestData('Third OHG', 'DE02300209000106531065', 'CMCIDEDD'); - call createHsOfficeBankAccountTestData('Fourth e.G.', 'DE02200505501015871393', 'HASPDEHH'); + call createHsOfficeBankAccountTestData('Fourth eG', 'DE02200505501015871393', 'HASPDEHH'); call createHsOfficeBankAccountTestData('Mel Bessler', 'DE02100100100006820101', 'PBNKDEFF'); call createHsOfficeBankAccountTestData('Anita Bessler', 'DE02300606010002474689', 'DAAEDEDD'); call createHsOfficeBankAccountTestData('Paul Winkler', 'DE02600501010002034304', 'SOLADEST600'); diff --git a/src/main/resources/db/changelog/5-hs-office/506-debitor/5060-hs-office-debitor.sql b/src/main/resources/db/changelog/5-hs-office/506-debitor/5060-hs-office-debitor.sql new file mode 100644 index 00000000..bbf72543 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/506-debitor/5060-hs-office-debitor.sql @@ -0,0 +1,65 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-debitor-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table hs_office_debitor +( + uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, + debitorNumberSuffix char(2) not null check (debitorNumberSuffix::text ~ '^[0-9][0-9]$'), + debitorRelUuid uuid not null references hs_office_relation(uuid), + billable boolean not null default true, + vatId varchar(24), + vatCountryCode varchar(2), + vatBusiness boolean not null, + vatReverseCharge boolean not null, + refundBankAccountUuid uuid references hs_office_bankaccount(uuid), + defaultPrefix char(3) not null unique + constraint check_default_prefix check ( + defaultPrefix::text ~ '^([a-z]{3}|al0|bh1|c4s|f3k|k8i|l3d|mh1|o13|p2m|s80|t4w)$' + ) +); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-DELETE-DEPENDENTS-TRIGGER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Trigger function to delete related rows of a debitor to delete. + */ +create or replace function deleteHsOfficeDependentsOnDebitorDelete() + returns trigger + language PLPGSQL +as $$ +declare + counter integer; +begin + DELETE FROM hs_office_relation r WHERE r.uuid = OLD.debitorRelUuid; + GET DIAGNOSTICS counter = ROW_COUNT; + if counter = 0 then + raise exception 'debitor relation % could not be deleted', OLD.debitorRelUuid; + end if; + + RETURN OLD; +end; $$; + +/** + Triggers deletion of related details of a debitor to delete. + */ +create trigger hs_office_debitor_delete_dependents_trigger + after delete + on hs_office_debitor + for each row +execute procedure deleteHsOfficeDependentsOnDebitorDelete(); + + +-- ============================================================================ +--changeset hs-office-debitor-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_office_debitor'); +--// diff --git a/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.md b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.md new file mode 100644 index 00000000..ef8bc404 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.md @@ -0,0 +1,196 @@ +### rbac debitor + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph debitor["`**debitor**`"] + direction TB + style debitor fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph debitor:permissions[ ] + style debitor:permissions fill:#dd4901,stroke:white + + perm:debitor:INSERT{{debitor:INSERT}} + perm:debitor:DELETE{{debitor:DELETE}} + perm:debitor:UPDATE{{debitor:UPDATE}} + perm:debitor:SELECT{{debitor:SELECT}} + end + + subgraph debitorRel["`**debitorRel**`"] + direction TB + style debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel:roles[ ] + style debitorRel:roles fill:#99bcdb,stroke:white + + role:debitorRel:OWNER[[debitorRel:OWNER]] + role:debitorRel:ADMIN[[debitorRel:ADMIN]] + role:debitorRel:AGENT[[debitorRel:AGENT]] + role:debitorRel:TENANT[[debitorRel:TENANT]] + end + end +end + +subgraph debitorRel.anchorPerson["`**debitorRel.anchorPerson**`"] + direction TB + style debitorRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.anchorPerson:roles[ ] + style debitorRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:debitorRel.anchorPerson:OWNER[[debitorRel.anchorPerson:OWNER]] + role:debitorRel.anchorPerson:ADMIN[[debitorRel.anchorPerson:ADMIN]] + role:debitorRel.anchorPerson:REFERRER[[debitorRel.anchorPerson:REFERRER]] + end +end + +subgraph debitorRel.contact["`**debitorRel.contact**`"] + direction TB + style debitorRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.contact:roles[ ] + style debitorRel.contact:roles fill:#99bcdb,stroke:white + + role:debitorRel.contact:OWNER[[debitorRel.contact:OWNER]] + role:debitorRel.contact:ADMIN[[debitorRel.contact:ADMIN]] + role:debitorRel.contact:REFERRER[[debitorRel.contact:REFERRER]] + end +end + +subgraph debitorRel.holderPerson["`**debitorRel.holderPerson**`"] + direction TB + style debitorRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.holderPerson:roles[ ] + style debitorRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:debitorRel.holderPerson:OWNER[[debitorRel.holderPerson:OWNER]] + role:debitorRel.holderPerson:ADMIN[[debitorRel.holderPerson:ADMIN]] + role:debitorRel.holderPerson:REFERRER[[debitorRel.holderPerson:REFERRER]] + end +end + +subgraph partnerRel["`**partnerRel**`"] + direction TB + style partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel:roles[ ] + style partnerRel:roles fill:#99bcdb,stroke:white + + role:partnerRel:OWNER[[partnerRel:OWNER]] + role:partnerRel:ADMIN[[partnerRel:ADMIN]] + role:partnerRel:AGENT[[partnerRel:AGENT]] + role:partnerRel:TENANT[[partnerRel:TENANT]] + end +end + +subgraph partnerRel.anchorPerson["`**partnerRel.anchorPerson**`"] + direction TB + style partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.anchorPerson:roles[ ] + style partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.anchorPerson:OWNER[[partnerRel.anchorPerson:OWNER]] + role:partnerRel.anchorPerson:ADMIN[[partnerRel.anchorPerson:ADMIN]] + role:partnerRel.anchorPerson:REFERRER[[partnerRel.anchorPerson:REFERRER]] + end +end + +subgraph partnerRel.contact["`**partnerRel.contact**`"] + direction TB + style partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.contact:roles[ ] + style partnerRel.contact:roles fill:#99bcdb,stroke:white + + role:partnerRel.contact:OWNER[[partnerRel.contact:OWNER]] + role:partnerRel.contact:ADMIN[[partnerRel.contact:ADMIN]] + role:partnerRel.contact:REFERRER[[partnerRel.contact:REFERRER]] + end +end + +subgraph partnerRel.holderPerson["`**partnerRel.holderPerson**`"] + direction TB + style partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.holderPerson:roles[ ] + style partnerRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.holderPerson:OWNER[[partnerRel.holderPerson:OWNER]] + role:partnerRel.holderPerson:ADMIN[[partnerRel.holderPerson:ADMIN]] + role:partnerRel.holderPerson:REFERRER[[partnerRel.holderPerson:REFERRER]] + end +end + +subgraph refundBankAccount["`**refundBankAccount**`"] + direction TB + style refundBankAccount fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph refundBankAccount:roles[ ] + style refundBankAccount:roles fill:#99bcdb,stroke:white + + role:refundBankAccount:OWNER[[refundBankAccount:OWNER]] + role:refundBankAccount:ADMIN[[refundBankAccount:ADMIN]] + role:refundBankAccount:REFERRER[[refundBankAccount:REFERRER]] + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:debitorRel.anchorPerson:OWNER +role:debitorRel.anchorPerson:OWNER -.-> role:debitorRel.anchorPerson:ADMIN +role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:debitorRel.holderPerson:OWNER +role:debitorRel.holderPerson:OWNER -.-> role:debitorRel.holderPerson:ADMIN +role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:debitorRel.contact:OWNER +role:debitorRel.contact:OWNER -.-> role:debitorRel.contact:ADMIN +role:debitorRel.contact:ADMIN -.-> role:debitorRel.contact:REFERRER +role:global:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel:OWNER -.-> role:debitorRel:ADMIN +role:debitorRel:ADMIN -.-> role:debitorRel:AGENT +role:debitorRel:AGENT -.-> role:debitorRel:TENANT +role:debitorRel.contact:ADMIN -.-> role:debitorRel:TENANT +role:debitorRel:TENANT -.-> role:debitorRel.anchorPerson:REFERRER +role:debitorRel:TENANT -.-> role:debitorRel.holderPerson:REFERRER +role:debitorRel:TENANT -.-> role:debitorRel.contact:REFERRER +role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel:AGENT +role:global:ADMIN -.-> role:refundBankAccount:OWNER +role:refundBankAccount:OWNER -.-> role:refundBankAccount:ADMIN +role:refundBankAccount:ADMIN -.-> role:refundBankAccount:REFERRER +role:refundBankAccount:ADMIN ==> role:debitorRel:AGENT +role:debitorRel:AGENT ==> role:refundBankAccount:REFERRER +role:global:ADMIN -.-> role:partnerRel.anchorPerson:OWNER +role:partnerRel.anchorPerson:OWNER -.-> role:partnerRel.anchorPerson:ADMIN +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.holderPerson:OWNER +role:partnerRel.holderPerson:OWNER -.-> role:partnerRel.holderPerson:ADMIN +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.contact:OWNER +role:partnerRel.contact:OWNER -.-> role:partnerRel.contact:ADMIN +role:partnerRel.contact:ADMIN -.-> role:partnerRel.contact:REFERRER +role:global:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel:OWNER -.-> role:partnerRel:ADMIN +role:partnerRel:ADMIN -.-> role:partnerRel:AGENT +role:partnerRel:AGENT -.-> role:partnerRel:TENANT +role:partnerRel.contact:ADMIN -.-> role:partnerRel:TENANT +role:partnerRel:TENANT -.-> role:partnerRel.anchorPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.holderPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.contact:REFERRER +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel:AGENT +role:partnerRel:ADMIN ==> role:debitorRel:ADMIN +role:partnerRel:AGENT ==> role:debitorRel:AGENT +role:debitorRel:AGENT ==> role:partnerRel:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:debitor:INSERT +role:debitorRel:OWNER ==> perm:debitor:DELETE +role:debitorRel:ADMIN ==> perm:debitor:UPDATE +role:debitorRel:TENANT ==> perm:debitor:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql new file mode 100644 index 00000000..8e91d7e7 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql @@ -0,0 +1,244 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_debitor'); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeDebitor', 'hs_office_debitor'); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeDebitor( + NEW hs_office_debitor +) + language plpgsql as $$ + +declare + newPartnerRel hs_office_relation; + newDebitorRel hs_office_relation; + newRefundBankAccount hs_office_bankaccount; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT partnerRel.* + FROM hs_office_relation AS partnerRel + JOIN hs_office_relation AS debitorRel + ON debitorRel.type = 'DEBITOR' AND debitorRel.anchorUuid = partnerRel.holderUuid + WHERE partnerRel.type = 'PARTNER' + AND NEW.debitorRelUuid = debitorRel.uuid + INTO newPartnerRel; + assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.debitorRelUuid = %s', NEW.debitorRelUuid); + + SELECT * FROM hs_office_relation WHERE uuid = NEW.debitorRelUuid INTO newDebitorRel; + assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorRelUuid = %s', NEW.debitorRelUuid); + + SELECT * FROM hs_office_bankaccount WHERE uuid = NEW.refundBankAccountUuid INTO newRefundBankAccount; + + call grantRoleToRole(hsOfficeBankAccountREFERRER(newRefundBankAccount), hsOfficeRelationAGENT(newDebitorRel)); + call grantRoleToRole(hsOfficeRelationADMIN(newDebitorRel), hsOfficeRelationADMIN(newPartnerRel)); + call grantRoleToRole(hsOfficeRelationAGENT(newDebitorRel), hsOfficeBankAccountADMIN(newRefundBankAccount)); + call grantRoleToRole(hsOfficeRelationAGENT(newDebitorRel), hsOfficeRelationAGENT(newPartnerRel)); + call grantRoleToRole(hsOfficeRelationTENANT(newPartnerRel), hsOfficeRelationAGENT(newDebitorRel)); + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), hsOfficeRelationOWNER(newDebitorRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'SELECT'), hsOfficeRelationTENANT(newDebitorRel)); + call grantPermissionToRole(createPermission(NEW.uuid, 'UPDATE'), hsOfficeRelationADMIN(newDebitorRel)); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_debitor row. + */ + +create or replace function insertTriggerForHsOfficeDebitor_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeDebitor(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeDebitor_tg + after insert on hs_office_debitor + for each row +execute procedure insertTriggerForHsOfficeDebitor_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForHsOfficeDebitor( + OLD hs_office_debitor, + NEW hs_office_debitor +) + language plpgsql as $$ +begin + + if NEW.debitorRelUuid is distinct from OLD.debitorRelUuid + or NEW.refundBankAccountUuid is distinct from OLD.refundBankAccountUuid then + delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid; + call buildRbacSystemForHsOfficeDebitor(NEW); + end if; +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_office_debitor row. + */ + +create or replace function updateTriggerForHsOfficeDebitor_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForHsOfficeDebitor(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForHsOfficeDebitor_tg + after update on hs_office_debitor + for each row +execute procedure updateTriggerForHsOfficeDebitor_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_office_debitor permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_office_debitor permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_debitor'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_office_debitor INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_office_debitor_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_debitor'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_debitor_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_office_debitor_grants_insert_to_global_tf(); + + +-- ============================================================================ +--changeset hs_office_debitor-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_debitor. +*/ +create or replace function hs_office_debitor_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + + raise exception '[403] insert into hs_office_debitor values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_debitor_insert_permission_check_tg + before insert on hs_office_debitor + for each row + execute procedure hs_office_debitor_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromQuery('hs_office_debitor', + $idName$ + SELECT debitor.uuid AS uuid, + 'D-' || (SELECT partner.partnerNumber + FROM hs_office_partner partner + JOIN hs_office_relation partnerRel + ON partnerRel.uuid = partner.partnerRelUUid AND partnerRel.type = 'PARTNER' + JOIN hs_office_relation debitorRel + ON debitorRel.anchorUuid = partnerRel.holderUuid AND debitorRel.type = 'DEBITOR' + WHERE debitorRel.uuid = debitor.debitorRelUuid) + || debitorNumberSuffix as idName + FROM hs_office_debitor AS debitor + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_debitor', + $orderBy$ + defaultPrefix + $orderBy$, + $updates$ + debitorRelUuid = new.debitorRelUuid, + billable = new.billable, + refundBankAccountUuid = new.refundBankAccountUuid, + vatId = new.vatId, + vatCountryCode = new.vatCountryCode, + vatBusiness = new.vatBusiness, + vatReverseCharge = new.vatReverseCharge, + defaultPrefix = new.defaultPrefix + $updates$); +--// + diff --git a/src/main/resources/db/changelog/5-hs-office/506-debitor/5068-hs-office-debitor-test-data.sql b/src/main/resources/db/changelog/5-hs-office/506-debitor/5068-hs-office-debitor-test-data.sql new file mode 100644 index 00000000..da9a5f2e --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/506-debitor/5068-hs-office-debitor-test-data.sql @@ -0,0 +1,60 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-debitor-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single debitor test record. + */ +create or replace procedure createHsOfficeDebitorTestData( + withDebitorNumberSuffix numeric(5), + forPartnerPersonName varchar, + forBillingContactCaption varchar, + withDefaultPrefix varchar + ) + language plpgsql as $$ +declare + idName varchar; + relatedDebitorRelUuid uuid; + relatedBankAccountUuid uuid; +begin + idName := cleanIdentifier( forPartnerPersonName|| '-' || forBillingContactCaption); + + select debitorRel.uuid + into relatedDebitorRelUuid + from hs_office_relation debitorRel + join hs_office_person person on person.uuid = debitorRel.holderUuid + and (person.tradeName = forPartnerPersonName or person.familyName = forPartnerPersonName) + where debitorRel.type = 'DEBITOR'; + + select b.uuid + into relatedBankAccountUuid + from hs_office_bankaccount b + where b.holder = forPartnerPersonName; + + raise notice 'creating test debitor: % (#%)', idName, withDebitorNumberSuffix; + -- raise exception 'creating test debitor: (uuid=%, debitorRelUuid=%, debitornumbersuffix=%, billable=%, vatbusiness=%, vatreversecharge=%, refundbankaccountuuid=%, defaultprefix=%)', + -- uuid_generate_v4(), relatedDebitorRelUuid, withDebitorNumberSuffix, true, true, false, relatedBankAccountUuid, withDefaultPrefix; + insert + into hs_office_debitor (uuid, debitorRelUuid, debitornumbersuffix, billable, vatbusiness, vatreversecharge, refundbankaccountuuid, defaultprefix) + values (uuid_generate_v4(), relatedDebitorRelUuid, withDebitorNumberSuffix, true, true, false, relatedBankAccountUuid, withDefaultPrefix); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-debitor-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating debitor test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsOfficeDebitorTestData(11, 'First GmbH', 'first contact', 'fir'); + call createHsOfficeDebitorTestData(12, 'Second e.K.', 'second contact', 'sec'); + call createHsOfficeDebitorTestData(13, 'Third OHG', 'third contact', 'thi'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/250-hs-office-sepamandate.sql b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5070-hs-office-sepamandate.sql similarity index 95% rename from src/main/resources/db/changelog/250-hs-office-sepamandate.sql rename to src/main/resources/db/changelog/5-hs-office/507-sepamandate/5070-hs-office-sepamandate.sql index fa60716f..c2ffd86d 100644 --- a/src/main/resources/db/changelog/250-hs-office-sepamandate.sql +++ b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5070-hs-office-sepamandate.sql @@ -7,6 +7,7 @@ create table if not exists hs_office_sepamandate ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, debitorUuid uuid not null references hs_office_debitor(uuid), bankAccountUuid uuid not null references hs_office_bankaccount(uuid), reference varchar(96) not null, diff --git a/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.md b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.md new file mode 100644 index 00000000..d6b47c0e --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.md @@ -0,0 +1,140 @@ +### rbac sepaMandate + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph bankAccount["`**bankAccount**`"] + direction TB + style bankAccount fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph bankAccount:roles[ ] + style bankAccount:roles fill:#99bcdb,stroke:white + + role:bankAccount:OWNER[[bankAccount:OWNER]] + role:bankAccount:ADMIN[[bankAccount:ADMIN]] + role:bankAccount:REFERRER[[bankAccount:REFERRER]] + end +end + +subgraph debitorRel["`**debitorRel**`"] + direction TB + style debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel:roles[ ] + style debitorRel:roles fill:#99bcdb,stroke:white + + role:debitorRel:OWNER[[debitorRel:OWNER]] + role:debitorRel:ADMIN[[debitorRel:ADMIN]] + role:debitorRel:AGENT[[debitorRel:AGENT]] + role:debitorRel:TENANT[[debitorRel:TENANT]] + end +end + +subgraph debitorRel.anchorPerson["`**debitorRel.anchorPerson**`"] + direction TB + style debitorRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.anchorPerson:roles[ ] + style debitorRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:debitorRel.anchorPerson:OWNER[[debitorRel.anchorPerson:OWNER]] + role:debitorRel.anchorPerson:ADMIN[[debitorRel.anchorPerson:ADMIN]] + role:debitorRel.anchorPerson:REFERRER[[debitorRel.anchorPerson:REFERRER]] + end +end + +subgraph debitorRel.contact["`**debitorRel.contact**`"] + direction TB + style debitorRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.contact:roles[ ] + style debitorRel.contact:roles fill:#99bcdb,stroke:white + + role:debitorRel.contact:OWNER[[debitorRel.contact:OWNER]] + role:debitorRel.contact:ADMIN[[debitorRel.contact:ADMIN]] + role:debitorRel.contact:REFERRER[[debitorRel.contact:REFERRER]] + end +end + +subgraph debitorRel.holderPerson["`**debitorRel.holderPerson**`"] + direction TB + style debitorRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel.holderPerson:roles[ ] + style debitorRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:debitorRel.holderPerson:OWNER[[debitorRel.holderPerson:OWNER]] + role:debitorRel.holderPerson:ADMIN[[debitorRel.holderPerson:ADMIN]] + role:debitorRel.holderPerson:REFERRER[[debitorRel.holderPerson:REFERRER]] + end +end + +subgraph sepaMandate["`**sepaMandate**`"] + direction TB + style sepaMandate fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph sepaMandate:roles[ ] + style sepaMandate:roles fill:#dd4901,stroke:white + + role:sepaMandate:OWNER[[sepaMandate:OWNER]] + role:sepaMandate:ADMIN[[sepaMandate:ADMIN]] + role:sepaMandate:AGENT[[sepaMandate:AGENT]] + role:sepaMandate:REFERRER[[sepaMandate:REFERRER]] + end + + subgraph sepaMandate:permissions[ ] + style sepaMandate:permissions fill:#dd4901,stroke:white + + perm:sepaMandate:DELETE{{sepaMandate:DELETE}} + perm:sepaMandate:UPDATE{{sepaMandate:UPDATE}} + perm:sepaMandate:SELECT{{sepaMandate:SELECT}} + perm:sepaMandate:INSERT{{sepaMandate:INSERT}} + end +end + +%% granting roles to users +user:creator ==> role:sepaMandate:OWNER + +%% granting roles to roles +role:global:ADMIN -.-> role:debitorRel.anchorPerson:OWNER +role:debitorRel.anchorPerson:OWNER -.-> role:debitorRel.anchorPerson:ADMIN +role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:debitorRel.holderPerson:OWNER +role:debitorRel.holderPerson:OWNER -.-> role:debitorRel.holderPerson:ADMIN +role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:debitorRel.contact:OWNER +role:debitorRel.contact:OWNER -.-> role:debitorRel.contact:ADMIN +role:debitorRel.contact:ADMIN -.-> role:debitorRel.contact:REFERRER +role:global:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel:OWNER -.-> role:debitorRel:ADMIN +role:debitorRel:ADMIN -.-> role:debitorRel:AGENT +role:debitorRel:AGENT -.-> role:debitorRel:TENANT +role:debitorRel.contact:ADMIN -.-> role:debitorRel:TENANT +role:debitorRel:TENANT -.-> role:debitorRel.anchorPerson:REFERRER +role:debitorRel:TENANT -.-> role:debitorRel.holderPerson:REFERRER +role:debitorRel:TENANT -.-> role:debitorRel.contact:REFERRER +role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel:AGENT +role:global:ADMIN -.-> role:bankAccount:OWNER +role:bankAccount:OWNER -.-> role:bankAccount:ADMIN +role:bankAccount:ADMIN -.-> role:bankAccount:REFERRER +role:global:ADMIN ==> role:sepaMandate:OWNER +role:sepaMandate:OWNER ==> role:sepaMandate:ADMIN +role:sepaMandate:ADMIN ==> role:sepaMandate:AGENT +role:sepaMandate:AGENT ==> role:bankAccount:REFERRER +role:sepaMandate:AGENT ==> role:debitorRel:AGENT +role:sepaMandate:AGENT ==> role:sepaMandate:REFERRER +role:bankAccount:ADMIN ==> role:sepaMandate:REFERRER +role:debitorRel:AGENT ==> role:sepaMandate:REFERRER +role:sepaMandate:REFERRER ==> role:debitorRel:TENANT + +%% granting permissions to roles +role:sepaMandate:OWNER ==> perm:sepaMandate:DELETE +role:sepaMandate:ADMIN ==> perm:sepaMandate:UPDATE +role:sepaMandate:REFERRER ==> perm:sepaMandate:SELECT +role:debitorRel:ADMIN ==> perm:sepaMandate:INSERT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql new file mode 100644 index 00000000..6b6595a0 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql @@ -0,0 +1,213 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_sepamandate'); +--// + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeSepaMandate', 'hs_office_sepamandate'); +--// + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeSepaMandate( + NEW hs_office_sepamandate +) + language plpgsql as $$ + +declare + newBankAccount hs_office_bankaccount; + newDebitorRel hs_office_relation; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_bankaccount WHERE uuid = NEW.bankAccountUuid INTO newBankAccount; + assert newBankAccount.uuid is not null, format('newBankAccount must not be null for NEW.bankAccountUuid = %s', NEW.bankAccountUuid); + + SELECT debitorRel.* + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = NEW.debitorUuid + INTO newDebitorRel; + assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s', NEW.debitorUuid); + + + perform createRoleWithGrants( + hsOfficeSepaMandateOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[globalADMIN()], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficeSepaMandateADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsOfficeSepaMandateOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsOfficeSepaMandateAGENT(NEW), + incomingSuperRoles => array[hsOfficeSepaMandateADMIN(NEW)], + outgoingSubRoles => array[ + hsOfficeBankAccountREFERRER(newBankAccount), + hsOfficeRelationAGENT(newDebitorRel)] + ); + + perform createRoleWithGrants( + hsOfficeSepaMandateREFERRER(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[ + hsOfficeBankAccountADMIN(newBankAccount), + hsOfficeRelationAGENT(newDebitorRel), + hsOfficeSepaMandateAGENT(NEW)], + outgoingSubRoles => array[hsOfficeRelationTENANT(newDebitorRel)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_sepamandate row. + */ + +create or replace function insertTriggerForHsOfficeSepaMandate_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeSepaMandate(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeSepaMandate_tg + after insert on hs_office_sepamandate + for each row +execute procedure insertTriggerForHsOfficeSepaMandate_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to hs_office_relation ---------------------------- + +/* + Grants INSERT INTO hs_office_sepamandate permissions to specified role of pre-existing hs_office_relation rows. + */ +do language plpgsql $$ + declare + row hs_office_relation; + begin + call defineContext('create INSERT INTO hs_office_sepamandate permissions for pre-exising hs_office_relation rows'); + + FOR row IN SELECT * FROM hs_office_relation + WHERE type = 'DEBITOR' + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_sepamandate'), + hsOfficeRelationADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_office_sepamandate INSERT permission to specified role of new hs_office_relation rows. +*/ +create or replace function new_hs_office_sepamandate_grants_insert_to_hs_office_relation_tf() + returns trigger + language plpgsql + strict as $$ +begin + if NEW.type = 'DEBITOR' then + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_sepamandate'), + hsOfficeRelationADMIN(NEW)); + end if; + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_sepamandate_grants_insert_to_hs_office_relation_tg + after insert on hs_office_relation + for each row +execute procedure new_hs_office_sepamandate_grants_insert_to_hs_office_relation_tf(); + + +-- ============================================================================ +--changeset hs_office_sepamandate-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_sepamandate. +*/ +create or replace function hs_office_sepamandate_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via indirect foreign key: NEW.debitorUuid + superObjectUuid := (SELECT debitorRel.uuid + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = NEW.debitorUuid + ); + assert superObjectUuid is not null, 'object uuid fetched depending on hs_office_sepamandate.debitorUuid must not be null, also check fetchSql in RBAC DSL'; + if hasInsertPermission(superObjectUuid, 'hs_office_sepamandate') then + return NEW; + end if; + + raise exception '[403] insert into hs_office_sepamandate values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_sepamandate_insert_permission_check_tg + before insert on hs_office_sepamandate + for each row + execute procedure hs_office_sepamandate_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromQuery('hs_office_sepamandate', + $idName$ + select sm.uuid as uuid, ba.iban || '-' || sm.validity as idName + from hs_office_sepamandate sm + join hs_office_bankaccount ba on ba.uuid = sm.bankAccountUuid + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-sepamandate-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_sepamandate', + $orderBy$ + validity + $orderBy$, + $updates$ + reference = new.reference, + agreement = new.agreement, + validity = new.validity + $updates$); +--// + diff --git a/src/main/resources/db/changelog/256-hs-office-sepamandate-migration.sql b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5076-hs-office-sepamandate-migration.sql similarity index 100% rename from src/main/resources/db/changelog/256-hs-office-sepamandate-migration.sql rename to src/main/resources/db/changelog/5-hs-office/507-sepamandate/5076-hs-office-sepamandate-migration.sql diff --git a/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5078-hs-office-sepamandate-test-data.sql b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5078-hs-office-sepamandate-test-data.sql new file mode 100644 index 00000000..6c8aa15e --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5078-hs-office-sepamandate-test-data.sql @@ -0,0 +1,53 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-sepaMandate-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single sepaMandate test record. + */ +create or replace procedure createHsOfficeSepaMandateTestData( + forPartnerNumber numeric(5), + forDebitorSuffix char(2), + forIban varchar, + withReference varchar) + language plpgsql as $$ +declare + relatedDebitor hs_office_debitor; + relatedBankAccount hs_office_bankAccount; +begin + select debitor.* into relatedDebitor + from hs_office_debitor debitor + join hs_office_relation debitorRel on debitorRel.uuid = debitor.debitorRelUuid + join hs_office_relation partnerRel on partnerRel.holderUuid = debitorRel.anchorUuid + join hs_office_partner partner on partner.partnerRelUuid = partnerRel.uuid + where partner.partnerNumber = forPartnerNumber and debitor.debitorNumberSuffix = forDebitorSuffix; + select b.* into relatedBankAccount + from hs_office_bankAccount b where b.iban = forIban; + + raise notice 'creating test SEPA-mandate: %', forPartnerNumber::text || forDebitorSuffix::text; + raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; + raise notice '- using bankAccount (%): %', relatedBankAccount.uuid, relatedBankAccount; + insert + into hs_office_sepamandate (uuid, debitoruuid, bankAccountuuid, reference, agreement, validity) + values (uuid_generate_v4(), relatedDebitor.uuid, relatedBankAccount.uuid, withReference, '20220930', daterange('20221001' , '20261231', '[]')); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-sepaMandate-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating SEPA-mandate test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsOfficeSepaMandateTestData(10001, '11', 'DE02120300000000202051', 'ref-10001-11'); + call createHsOfficeSepaMandateTestData(10002, '12', 'DE02100500000054540402', 'ref-10002-12'); + call createHsOfficeSepaMandateTestData(10003, '13', 'DE02300209000106531065', 'ref-10003-13'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/300-hs-office-membership.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql similarity index 65% rename from src/main/resources/db/changelog/300-hs-office-membership.sql rename to src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql index acc0651a..47831f9d 100644 --- a/src/main/resources/db/changelog/300-hs-office-membership.sql +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql @@ -4,19 +4,27 @@ --changeset hs-office-membership-MAIN-TABLE:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -CREATE TYPE HsOfficeReasonForTermination AS ENUM ('NONE', 'CANCELLATION', 'TRANSFER', 'DEATH', 'LIQUIDATION', 'EXPULSION', 'UNKNOWN'); +CREATE TYPE HsOfficeMembershipStatus AS ENUM ( + 'INVALID', + 'ACTIVE', + 'CANCELLED', + 'TRANSFERRED', + 'DECEASED', + 'LIQUIDATED', + 'EXPULSED', + 'UNKNOWN' +); -CREATE CAST (character varying as HsOfficeReasonForTermination) WITH INOUT AS IMPLICIT; +CREATE CAST (character varying as HsOfficeMembershipStatus) WITH INOUT AS IMPLICIT; create table if not exists hs_office_membership ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, partnerUuid uuid not null references hs_office_partner(uuid), - mainDebitorUuid uuid not null references hs_office_debitor(uuid), - memberNumberSuffix char(2) not null check ( - memberNumberSuffix::text ~ '^[0-9][0-9]$'), + memberNumberSuffix char(2) not null check (memberNumberSuffix::text ~ '^[0-9][0-9]$'), validity daterange not null, - reasonForTermination HsOfficeReasonForTermination not null default 'NONE', + status HsOfficeMembershipStatus not null default 'ACTIVE', membershipFeeBillable boolean not null default true, UNIQUE(partnerUuid, memberNumberSuffix) diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.md b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.md new file mode 100644 index 00000000..083e244e --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.md @@ -0,0 +1,119 @@ +### rbac membership + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph membership["`**membership**`"] + direction TB + style membership fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph membership:roles[ ] + style membership:roles fill:#dd4901,stroke:white + + role:membership:OWNER[[membership:OWNER]] + role:membership:ADMIN[[membership:ADMIN]] + role:membership:AGENT[[membership:AGENT]] + end + + subgraph membership:permissions[ ] + style membership:permissions fill:#dd4901,stroke:white + + perm:membership:INSERT{{membership:INSERT}} + perm:membership:DELETE{{membership:DELETE}} + perm:membership:UPDATE{{membership:UPDATE}} + perm:membership:SELECT{{membership:SELECT}} + end +end + +subgraph partnerRel["`**partnerRel**`"] + direction TB + style partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel:roles[ ] + style partnerRel:roles fill:#99bcdb,stroke:white + + role:partnerRel:OWNER[[partnerRel:OWNER]] + role:partnerRel:ADMIN[[partnerRel:ADMIN]] + role:partnerRel:AGENT[[partnerRel:AGENT]] + role:partnerRel:TENANT[[partnerRel:TENANT]] + end +end + +subgraph partnerRel.anchorPerson["`**partnerRel.anchorPerson**`"] + direction TB + style partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.anchorPerson:roles[ ] + style partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.anchorPerson:OWNER[[partnerRel.anchorPerson:OWNER]] + role:partnerRel.anchorPerson:ADMIN[[partnerRel.anchorPerson:ADMIN]] + role:partnerRel.anchorPerson:REFERRER[[partnerRel.anchorPerson:REFERRER]] + end +end + +subgraph partnerRel.contact["`**partnerRel.contact**`"] + direction TB + style partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.contact:roles[ ] + style partnerRel.contact:roles fill:#99bcdb,stroke:white + + role:partnerRel.contact:OWNER[[partnerRel.contact:OWNER]] + role:partnerRel.contact:ADMIN[[partnerRel.contact:ADMIN]] + role:partnerRel.contact:REFERRER[[partnerRel.contact:REFERRER]] + end +end + +subgraph partnerRel.holderPerson["`**partnerRel.holderPerson**`"] + direction TB + style partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph partnerRel.holderPerson:roles[ ] + style partnerRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:partnerRel.holderPerson:OWNER[[partnerRel.holderPerson:OWNER]] + role:partnerRel.holderPerson:ADMIN[[partnerRel.holderPerson:ADMIN]] + role:partnerRel.holderPerson:REFERRER[[partnerRel.holderPerson:REFERRER]] + end +end + +%% granting roles to users +user:creator ==> role:membership:OWNER + +%% granting roles to roles +role:global:ADMIN -.-> role:partnerRel.anchorPerson:OWNER +role:partnerRel.anchorPerson:OWNER -.-> role:partnerRel.anchorPerson:ADMIN +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.holderPerson:OWNER +role:partnerRel.holderPerson:OWNER -.-> role:partnerRel.holderPerson:ADMIN +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:partnerRel.contact:OWNER +role:partnerRel.contact:OWNER -.-> role:partnerRel.contact:ADMIN +role:partnerRel.contact:ADMIN -.-> role:partnerRel.contact:REFERRER +role:global:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel:OWNER -.-> role:partnerRel:ADMIN +role:partnerRel:ADMIN -.-> role:partnerRel:AGENT +role:partnerRel:AGENT -.-> role:partnerRel:TENANT +role:partnerRel.contact:ADMIN -.-> role:partnerRel:TENANT +role:partnerRel:TENANT -.-> role:partnerRel.anchorPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.holderPerson:REFERRER +role:partnerRel:TENANT -.-> role:partnerRel.contact:REFERRER +role:partnerRel.anchorPerson:ADMIN -.-> role:partnerRel:OWNER +role:partnerRel.holderPerson:ADMIN -.-> role:partnerRel:AGENT +role:membership:OWNER ==> role:membership:ADMIN +role:partnerRel:ADMIN ==> role:membership:ADMIN +role:membership:ADMIN ==> role:membership:AGENT +role:partnerRel:AGENT ==> role:membership:AGENT +role:membership:AGENT ==> role:partnerRel:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:membership:INSERT +role:membership:ADMIN ==> perm:membership:DELETE +role:membership:ADMIN ==> perm:membership:UPDATE +role:membership:AGENT ==> perm:membership:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql new file mode 100644 index 00000000..7e628d39 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql @@ -0,0 +1,195 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-membership-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_membership'); +--// + + +-- ============================================================================ +--changeset hs-office-membership-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeMembership', 'hs_office_membership'); +--// + + +-- ============================================================================ +--changeset hs-office-membership-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeMembership( + NEW hs_office_membership +) + language plpgsql as $$ + +declare + newPartnerRel hs_office_relation; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT partnerRel.* + FROM hs_office_partner AS partner + JOIN hs_office_relation AS partnerRel ON partnerRel.uuid = partner.partnerRelUuid + WHERE partner.uuid = NEW.partnerUuid + INTO newPartnerRel; + assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerUuid = %s', NEW.partnerUuid); + + + perform createRoleWithGrants( + hsOfficeMembershipOWNER(NEW), + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsOfficeMembershipADMIN(NEW), + permissions => array['DELETE', 'UPDATE'], + incomingSuperRoles => array[ + hsOfficeMembershipOWNER(NEW), + hsOfficeRelationADMIN(newPartnerRel)] + ); + + perform createRoleWithGrants( + hsOfficeMembershipAGENT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[ + hsOfficeMembershipADMIN(NEW), + hsOfficeRelationAGENT(newPartnerRel)], + outgoingSubRoles => array[hsOfficeRelationTENANT(newPartnerRel)] + ); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_membership row. + */ + +create or replace function insertTriggerForHsOfficeMembership_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeMembership(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeMembership_tg + after insert on hs_office_membership + for each row +execute procedure insertTriggerForHsOfficeMembership_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-membership-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_office_membership permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_office_membership permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_membership'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_office_membership INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_office_membership_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_membership'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_membership_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_office_membership_grants_insert_to_global_tf(); + + +-- ============================================================================ +--changeset hs_office_membership-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_membership. +*/ +create or replace function hs_office_membership_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + + raise exception '[403] insert into hs_office_membership values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_membership_insert_permission_check_tg + before insert on hs_office_membership + for each row + execute procedure hs_office_membership_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-membership-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromQuery('hs_office_membership', + $idName$ + SELECT m.uuid AS uuid, + 'M-' || p.partnerNumber || m.memberNumberSuffix as idName + FROM hs_office_membership AS m + JOIN hs_office_partner AS p ON p.uuid = m.partnerUuid + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-membership-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_membership', + $orderBy$ + validity + $orderBy$, + $updates$ + validity = new.validity, + membershipFeeBillable = new.membershipFeeBillable, + status = new.status + $updates$); +--// + diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql new file mode 100644 index 00000000..205efcc9 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql @@ -0,0 +1,43 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-membership-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single membership test record. + */ +create or replace procedure createHsOfficeMembershipTestData( + forPartnerNumber numeric(5), + newMemberNumberSuffix char(2) ) + language plpgsql as $$ +declare + relatedPartner hs_office_partner; +begin + select partner.* from hs_office_partner partner + where partner.partnerNumber = forPartnerNumber into relatedPartner; + + raise notice 'creating test Membership: M-% %', forPartnerNumber, newMemberNumberSuffix; + raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; + insert + into hs_office_membership (uuid, partneruuid, memberNumberSuffix, validity, status) + values (uuid_generate_v4(), relatedPartner.uuid, newMemberNumberSuffix, daterange('20221001' , null, '[]'), 'ACTIVE'); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-membership-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating Membership test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsOfficeMembershipTestData(10001, '01'); + call createHsOfficeMembershipTestData(10002, '02'); + call createHsOfficeMembershipTestData(10003, '03'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/310-hs-office-coopshares.sql b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5110-hs-office-coopshares.sql similarity index 73% rename from src/main/resources/db/changelog/310-hs-office-coopshares.sql rename to src/main/resources/db/changelog/5-hs-office/511-coopshares/5110-hs-office-coopshares.sql index 4ba70ecc..599c9cfc 100644 --- a/src/main/resources/db/changelog/310-hs-office-coopshares.sql +++ b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5110-hs-office-coopshares.sql @@ -11,15 +11,27 @@ CREATE CAST (character varying as HsOfficeCoopSharesTransactionType) WITH INOUT create table if not exists hs_office_coopsharestransaction ( uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, membershipUuid uuid not null references hs_office_membership(uuid), transactionType HsOfficeCoopSharesTransactionType not null, valueDate date not null, - shareCount integer, - reference varchar(48), + shareCount integer not null, + reference varchar(48) not null, + adjustedShareTxUuid uuid unique REFERENCES hs_office_coopsharestransaction(uuid) DEFERRABLE INITIALLY DEFERRED, comment varchar(512) ); --// +-- ============================================================================ +--changeset hs-office-coopshares-BUSINESS-RULES:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +alter table hs_office_coopsharestransaction + add constraint hs_office_coopsharestransaction_reverse_entry_missing + check ( transactionType = 'ADJUSTMENT' and adjustedShareTxUuid is not null + or transactionType <> 'ADJUSTMENT' and adjustedShareTxUuid is null); +--// + -- ============================================================================ --changeset hs-office-coopshares-SHARE-COUNT-CONSTRAINT:1 endDelimiter:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.md b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.md new file mode 100644 index 00000000..23103840 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.md @@ -0,0 +1,119 @@ +### rbac coopSharesTransaction + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph coopSharesTransaction["`**coopSharesTransaction**`"] + direction TB + style coopSharesTransaction fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph coopSharesTransaction:permissions[ ] + style coopSharesTransaction:permissions fill:#dd4901,stroke:white + + perm:coopSharesTransaction:INSERT{{coopSharesTransaction:INSERT}} + perm:coopSharesTransaction:UPDATE{{coopSharesTransaction:UPDATE}} + perm:coopSharesTransaction:SELECT{{coopSharesTransaction:SELECT}} + end +end + +subgraph membership["`**membership**`"] + direction TB + style membership fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership:roles[ ] + style membership:roles fill:#99bcdb,stroke:white + + role:membership:OWNER[[membership:OWNER]] + role:membership:ADMIN[[membership:ADMIN]] + role:membership:AGENT[[membership:AGENT]] + end +end + +subgraph membership.partnerRel["`**membership.partnerRel**`"] + direction TB + style membership.partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel:roles[ ] + style membership.partnerRel:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel:OWNER[[membership.partnerRel:OWNER]] + role:membership.partnerRel:ADMIN[[membership.partnerRel:ADMIN]] + role:membership.partnerRel:AGENT[[membership.partnerRel:AGENT]] + role:membership.partnerRel:TENANT[[membership.partnerRel:TENANT]] + end +end + +subgraph membership.partnerRel.anchorPerson["`**membership.partnerRel.anchorPerson**`"] + direction TB + style membership.partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.anchorPerson:roles[ ] + style membership.partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.anchorPerson:OWNER[[membership.partnerRel.anchorPerson:OWNER]] + role:membership.partnerRel.anchorPerson:ADMIN[[membership.partnerRel.anchorPerson:ADMIN]] + role:membership.partnerRel.anchorPerson:REFERRER[[membership.partnerRel.anchorPerson:REFERRER]] + end +end + +subgraph membership.partnerRel.contact["`**membership.partnerRel.contact**`"] + direction TB + style membership.partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.contact:roles[ ] + style membership.partnerRel.contact:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.contact:OWNER[[membership.partnerRel.contact:OWNER]] + role:membership.partnerRel.contact:ADMIN[[membership.partnerRel.contact:ADMIN]] + role:membership.partnerRel.contact:REFERRER[[membership.partnerRel.contact:REFERRER]] + end +end + +subgraph membership.partnerRel.holderPerson["`**membership.partnerRel.holderPerson**`"] + direction TB + style membership.partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.holderPerson:roles[ ] + style membership.partnerRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.holderPerson:OWNER[[membership.partnerRel.holderPerson:OWNER]] + role:membership.partnerRel.holderPerson:ADMIN[[membership.partnerRel.holderPerson:ADMIN]] + role:membership.partnerRel.holderPerson:REFERRER[[membership.partnerRel.holderPerson:REFERRER]] + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:membership.partnerRel.anchorPerson:OWNER +role:membership.partnerRel.anchorPerson:OWNER -.-> role:membership.partnerRel.anchorPerson:ADMIN +role:membership.partnerRel.anchorPerson:ADMIN -.-> role:membership.partnerRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel.holderPerson:OWNER +role:membership.partnerRel.holderPerson:OWNER -.-> role:membership.partnerRel.holderPerson:ADMIN +role:membership.partnerRel.holderPerson:ADMIN -.-> role:membership.partnerRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel.contact:OWNER +role:membership.partnerRel.contact:OWNER -.-> role:membership.partnerRel.contact:ADMIN +role:membership.partnerRel.contact:ADMIN -.-> role:membership.partnerRel.contact:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel:OWNER +role:membership.partnerRel:OWNER -.-> role:membership.partnerRel:ADMIN +role:membership.partnerRel:ADMIN -.-> role:membership.partnerRel:AGENT +role:membership.partnerRel:AGENT -.-> role:membership.partnerRel:TENANT +role:membership.partnerRel.contact:ADMIN -.-> role:membership.partnerRel:TENANT +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.anchorPerson:REFERRER +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.holderPerson:REFERRER +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.contact:REFERRER +role:membership.partnerRel.anchorPerson:ADMIN -.-> role:membership.partnerRel:OWNER +role:membership.partnerRel.holderPerson:ADMIN -.-> role:membership.partnerRel:AGENT +role:membership:OWNER -.-> role:membership:ADMIN +role:membership.partnerRel:ADMIN -.-> role:membership:ADMIN +role:membership:ADMIN -.-> role:membership:AGENT +role:membership.partnerRel:AGENT -.-> role:membership:AGENT +role:membership:AGENT -.-> role:membership.partnerRel:TENANT + +%% granting permissions to roles +role:membership:ADMIN ==> perm:coopSharesTransaction:INSERT +role:membership:ADMIN ==> perm:coopSharesTransaction:UPDATE +role:membership:AGENT ==> perm:coopSharesTransaction:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql new file mode 100644 index 00000000..6707bdaa --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql @@ -0,0 +1,166 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_coopsharestransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeCoopSharesTransaction', 'hs_office_coopsharestransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeCoopSharesTransaction( + NEW hs_office_coopsharestransaction +) + language plpgsql as $$ + +declare + newMembership hs_office_membership; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_membership WHERE uuid = NEW.membershipUuid INTO newMembership; + assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s', NEW.membershipUuid); + + call grantPermissionToRole(createPermission(NEW.uuid, 'SELECT'), hsOfficeMembershipAGENT(newMembership)); + call grantPermissionToRole(createPermission(NEW.uuid, 'UPDATE'), hsOfficeMembershipADMIN(newMembership)); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_coopsharestransaction row. + */ + +create or replace function insertTriggerForHsOfficeCoopSharesTransaction_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeCoopSharesTransaction(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeCoopSharesTransaction_tg + after insert on hs_office_coopsharestransaction + for each row +execute procedure insertTriggerForHsOfficeCoopSharesTransaction_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to hs_office_membership ---------------------------- + +/* + Grants INSERT INTO hs_office_coopsharestransaction permissions to specified role of pre-existing hs_office_membership rows. + */ +do language plpgsql $$ + declare + row hs_office_membership; + begin + call defineContext('create INSERT INTO hs_office_coopsharestransaction permissions for pre-exising hs_office_membership rows'); + + FOR row IN SELECT * FROM hs_office_membership + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_coopsharestransaction'), + hsOfficeMembershipADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_office_coopsharestransaction INSERT permission to specified role of new hs_office_membership rows. +*/ +create or replace function new_hs_office_coopsharestransaction_grants_insert_to_hs_office_membership_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_coopsharestransaction'), + hsOfficeMembershipADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_coopsharestransaction_grants_insert_to_hs_office_membership_tg + after insert on hs_office_membership + for each row +execute procedure new_hs_office_coopsharestransaction_grants_insert_to_hs_office_membership_tf(); + + +-- ============================================================================ +--changeset hs_office_coopsharestransaction-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_coopsharestransaction. +*/ +create or replace function hs_office_coopsharestransaction_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via direct foreign key: NEW.membershipUuid + if hasInsertPermission(NEW.membershipUuid, 'hs_office_coopsharestransaction') then + return NEW; + end if; + + raise exception '[403] insert into hs_office_coopsharestransaction values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_coopsharestransaction_insert_permission_check_tg + before insert on hs_office_coopsharestransaction + for each row + execute procedure hs_office_coopsharestransaction_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_coopsharestransaction', + $idName$ + reference + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-coopsharestransaction-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_coopsharestransaction', + $orderBy$ + reference + $orderBy$, + $updates$ + comment = new.comment + $updates$); +--// + diff --git a/src/main/resources/db/changelog/316-hs-office-coopshares-migration.sql b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5116-hs-office-coopshares-migration.sql similarity index 100% rename from src/main/resources/db/changelog/316-hs-office-coopshares-migration.sql rename to src/main/resources/db/changelog/5-hs-office/511-coopshares/5116-hs-office-coopshares-migration.sql diff --git a/src/main/resources/db/changelog/318-hs-office-coopshares-test-data.sql b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5118-hs-office-coopshares-test-data.sql similarity index 71% rename from src/main/resources/db/changelog/318-hs-office-coopshares-test-data.sql rename to src/main/resources/db/changelog/5-hs-office/511-coopshares/5118-hs-office-coopshares-test-data.sql index c3d2bf98..4efb55db 100644 --- a/src/main/resources/db/changelog/318-hs-office-coopshares-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5118-hs-office-coopshares-test-data.sql @@ -14,13 +14,9 @@ create or replace procedure createHsOfficeCoopSharesTransactionTestData( ) language plpgsql as $$ declare - currentTask varchar; - membership hs_office_membership; + membership hs_office_membership; + subscriptionEntryUuid uuid; begin - currentTask = 'creating coopSharesTransaction test-data ' || givenPartnerNumber::text || givenMemberNumberSuffix; - execute format('set local hsadminng.currentTask to %L', currentTask); - - call defineContext(currentTask); select m.uuid from hs_office_membership m join hs_office_partner p on p.uuid = m.partneruuid @@ -29,12 +25,14 @@ begin into membership; raise notice 'creating test coopSharesTransaction: %', givenPartnerNumber::text || givenMemberNumberSuffix; + subscriptionEntryUuid := uuid_generate_v4(); insert - into hs_office_coopsharestransaction(uuid, membershipuuid, transactiontype, valuedate, sharecount, reference, comment) + into hs_office_coopsharestransaction(uuid, membershipuuid, transactiontype, valuedate, sharecount, reference, comment, adjustedShareTxUuid) values - (uuid_generate_v4(), membership.uuid, 'SUBSCRIPTION', '2010-03-15', 4, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-1', 'initial subscription'), - (uuid_generate_v4(), membership.uuid, 'CANCELLATION', '2021-09-01', -2, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-2', 'cancelling some'), - (uuid_generate_v4(), membership.uuid, 'ADJUSTMENT', '2022-10-20', 2, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-3', 'some adjustment'); + (uuid_generate_v4(), membership.uuid, 'SUBSCRIPTION', '2010-03-15', 4, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-1', 'initial subscription', null), + (uuid_generate_v4(), membership.uuid, 'CANCELLATION', '2021-09-01', -2, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-2', 'cancelling some', null), + (subscriptionEntryUuid, membership.uuid, 'SUBSCRIPTION', '2022-10-20', 2, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-3', 'some subscription', null), + (uuid_generate_v4(), membership.uuid, 'ADJUSTMENT', '2022-10-21', -2, 'ref '||givenPartnerNumber::text || givenMemberNumberSuffix||'-4', 'some adjustment', subscriptionEntryUuid); end; $$; --// @@ -45,6 +43,9 @@ end; $$; do language plpgsql $$ begin + call defineContext('creating coopSharesTransaction test-data'); + SET CONSTRAINTS ALL DEFERRED; + call createHsOfficeCoopSharesTransactionTestData(10001, '01'); call createHsOfficeCoopSharesTransactionTestData(10002, '02'); call createHsOfficeCoopSharesTransactionTestData(10003, '03'); diff --git a/src/main/resources/db/changelog/320-hs-office-coopassets.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql similarity index 63% rename from src/main/resources/db/changelog/320-hs-office-coopassets.sql rename to src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql index 9a712f3a..289d5c2e 100644 --- a/src/main/resources/db/changelog/320-hs-office-coopassets.sql +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql @@ -17,16 +17,29 @@ CREATE CAST (character varying as HsOfficeCoopAssetsTransactionType) WITH INOUT create table if not exists hs_office_coopassetstransaction ( - uuid uuid unique references RbacObject (uuid) initially deferred, - membershipUuid uuid not null references hs_office_membership(uuid), - transactionType HsOfficeCoopAssetsTransactionType not null, - valueDate date not null, - assetValue money, - reference varchar(48), - comment varchar(512) + uuid uuid unique references RbacObject (uuid) initially deferred, + version int not null default 0, + membershipUuid uuid not null references hs_office_membership(uuid), + transactionType HsOfficeCoopAssetsTransactionType not null, + valueDate date not null, + assetValue money not null, + reference varchar(48) not null, + adjustedAssetTxUuid uuid unique REFERENCES hs_office_coopassetstransaction(uuid) DEFERRABLE INITIALLY DEFERRED, + comment varchar(512) ); --// + +-- ============================================================================ +--changeset hs-office-coopassets-BUSINESS-RULES:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +alter table hs_office_coopassetstransaction + add constraint hs_office_coopassetstransaction_reverse_entry_missing + check ( transactionType = 'ADJUSTMENT' and adjustedAssetTxUuid is not null + or transactionType <> 'ADJUSTMENT' and adjustedAssetTxUuid is null); +--// + -- ============================================================================ --changeset hs-office-coopassets-ASSET-VALUE-CONSTRAINT:1 endDelimiter:--// -- ---------------------------------------------------------------------------- @@ -39,9 +52,9 @@ declare totalAssetValue money; begin select sum(cat.assetValue) - from hs_office_coopassetstransaction cat - where cat.membershipUuid = forMembershipUuid - into currentAssetValue; + from hs_office_coopassetstransaction cat + where cat.membershipUuid = forMembershipUuid + into currentAssetValue; totalAssetValue := currentAssetValue + newAssetValue; if totalAssetValue::numeric < 0 then raise exception '[400] coop assets transaction would result in a negative balance of assets'; @@ -52,9 +65,9 @@ end; $$; alter table hs_office_coopassetstransaction add constraint hs_office_coopassets_positive check ( checkAssetsByMembershipUuid(membershipUuid, assetValue) ); - --// + -- ============================================================================ --changeset hs-office-coopassets-MAIN-TABLE-JOURNAL:1 endDelimiter:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.md b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.md new file mode 100644 index 00000000..de30185b --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.md @@ -0,0 +1,119 @@ +### rbac coopAssetsTransaction + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph coopAssetsTransaction["`**coopAssetsTransaction**`"] + direction TB + style coopAssetsTransaction fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph coopAssetsTransaction:permissions[ ] + style coopAssetsTransaction:permissions fill:#dd4901,stroke:white + + perm:coopAssetsTransaction:INSERT{{coopAssetsTransaction:INSERT}} + perm:coopAssetsTransaction:UPDATE{{coopAssetsTransaction:UPDATE}} + perm:coopAssetsTransaction:SELECT{{coopAssetsTransaction:SELECT}} + end +end + +subgraph membership["`**membership**`"] + direction TB + style membership fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership:roles[ ] + style membership:roles fill:#99bcdb,stroke:white + + role:membership:OWNER[[membership:OWNER]] + role:membership:ADMIN[[membership:ADMIN]] + role:membership:AGENT[[membership:AGENT]] + end +end + +subgraph membership.partnerRel["`**membership.partnerRel**`"] + direction TB + style membership.partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel:roles[ ] + style membership.partnerRel:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel:OWNER[[membership.partnerRel:OWNER]] + role:membership.partnerRel:ADMIN[[membership.partnerRel:ADMIN]] + role:membership.partnerRel:AGENT[[membership.partnerRel:AGENT]] + role:membership.partnerRel:TENANT[[membership.partnerRel:TENANT]] + end +end + +subgraph membership.partnerRel.anchorPerson["`**membership.partnerRel.anchorPerson**`"] + direction TB + style membership.partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.anchorPerson:roles[ ] + style membership.partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.anchorPerson:OWNER[[membership.partnerRel.anchorPerson:OWNER]] + role:membership.partnerRel.anchorPerson:ADMIN[[membership.partnerRel.anchorPerson:ADMIN]] + role:membership.partnerRel.anchorPerson:REFERRER[[membership.partnerRel.anchorPerson:REFERRER]] + end +end + +subgraph membership.partnerRel.contact["`**membership.partnerRel.contact**`"] + direction TB + style membership.partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.contact:roles[ ] + style membership.partnerRel.contact:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.contact:OWNER[[membership.partnerRel.contact:OWNER]] + role:membership.partnerRel.contact:ADMIN[[membership.partnerRel.contact:ADMIN]] + role:membership.partnerRel.contact:REFERRER[[membership.partnerRel.contact:REFERRER]] + end +end + +subgraph membership.partnerRel.holderPerson["`**membership.partnerRel.holderPerson**`"] + direction TB + style membership.partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph membership.partnerRel.holderPerson:roles[ ] + style membership.partnerRel.holderPerson:roles fill:#99bcdb,stroke:white + + role:membership.partnerRel.holderPerson:OWNER[[membership.partnerRel.holderPerson:OWNER]] + role:membership.partnerRel.holderPerson:ADMIN[[membership.partnerRel.holderPerson:ADMIN]] + role:membership.partnerRel.holderPerson:REFERRER[[membership.partnerRel.holderPerson:REFERRER]] + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:membership.partnerRel.anchorPerson:OWNER +role:membership.partnerRel.anchorPerson:OWNER -.-> role:membership.partnerRel.anchorPerson:ADMIN +role:membership.partnerRel.anchorPerson:ADMIN -.-> role:membership.partnerRel.anchorPerson:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel.holderPerson:OWNER +role:membership.partnerRel.holderPerson:OWNER -.-> role:membership.partnerRel.holderPerson:ADMIN +role:membership.partnerRel.holderPerson:ADMIN -.-> role:membership.partnerRel.holderPerson:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel.contact:OWNER +role:membership.partnerRel.contact:OWNER -.-> role:membership.partnerRel.contact:ADMIN +role:membership.partnerRel.contact:ADMIN -.-> role:membership.partnerRel.contact:REFERRER +role:global:ADMIN -.-> role:membership.partnerRel:OWNER +role:membership.partnerRel:OWNER -.-> role:membership.partnerRel:ADMIN +role:membership.partnerRel:ADMIN -.-> role:membership.partnerRel:AGENT +role:membership.partnerRel:AGENT -.-> role:membership.partnerRel:TENANT +role:membership.partnerRel.contact:ADMIN -.-> role:membership.partnerRel:TENANT +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.anchorPerson:REFERRER +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.holderPerson:REFERRER +role:membership.partnerRel:TENANT -.-> role:membership.partnerRel.contact:REFERRER +role:membership.partnerRel.anchorPerson:ADMIN -.-> role:membership.partnerRel:OWNER +role:membership.partnerRel.holderPerson:ADMIN -.-> role:membership.partnerRel:AGENT +role:membership:OWNER -.-> role:membership:ADMIN +role:membership.partnerRel:ADMIN -.-> role:membership:ADMIN +role:membership:ADMIN -.-> role:membership:AGENT +role:membership.partnerRel:AGENT -.-> role:membership:AGENT +role:membership:AGENT -.-> role:membership.partnerRel:TENANT + +%% granting permissions to roles +role:membership:ADMIN ==> perm:coopAssetsTransaction:INSERT +role:membership:ADMIN ==> perm:coopAssetsTransaction:UPDATE +role:membership:AGENT ==> perm:coopAssetsTransaction:SELECT + +``` diff --git a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql new file mode 100644 index 00000000..39f5a8fe --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql @@ -0,0 +1,166 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_coopassetstransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeCoopAssetsTransaction', 'hs_office_coopassetstransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsOfficeCoopAssetsTransaction( + NEW hs_office_coopassetstransaction +) + language plpgsql as $$ + +declare + newMembership hs_office_membership; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_membership WHERE uuid = NEW.membershipUuid INTO newMembership; + assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s', NEW.membershipUuid); + + call grantPermissionToRole(createPermission(NEW.uuid, 'SELECT'), hsOfficeMembershipAGENT(newMembership)); + call grantPermissionToRole(createPermission(NEW.uuid, 'UPDATE'), hsOfficeMembershipADMIN(newMembership)); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_office_coopassetstransaction row. + */ + +create or replace function insertTriggerForHsOfficeCoopAssetsTransaction_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsOfficeCoopAssetsTransaction(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsOfficeCoopAssetsTransaction_tg + after insert on hs_office_coopassetstransaction + for each row +execute procedure insertTriggerForHsOfficeCoopAssetsTransaction_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to hs_office_membership ---------------------------- + +/* + Grants INSERT INTO hs_office_coopassetstransaction permissions to specified role of pre-existing hs_office_membership rows. + */ +do language plpgsql $$ + declare + row hs_office_membership; + begin + call defineContext('create INSERT INTO hs_office_coopassetstransaction permissions for pre-exising hs_office_membership rows'); + + FOR row IN SELECT * FROM hs_office_membership + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_office_coopassetstransaction'), + hsOfficeMembershipADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_office_coopassetstransaction INSERT permission to specified role of new hs_office_membership rows. +*/ +create or replace function new_hs_office_coopassetstransaction_grants_insert_to_hs_office_membership_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_office_coopassetstransaction'), + hsOfficeMembershipADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_office_coopassetstransaction_grants_insert_to_hs_office_membership_tg + after insert on hs_office_membership + for each row +execute procedure new_hs_office_coopassetstransaction_grants_insert_to_hs_office_membership_tf(); + + +-- ============================================================================ +--changeset hs_office_coopassetstransaction-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_office_coopassetstransaction. +*/ +create or replace function hs_office_coopassetstransaction_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via direct foreign key: NEW.membershipUuid + if hasInsertPermission(NEW.membershipUuid, 'hs_office_coopassetstransaction') then + return NEW; + end if; + + raise exception '[403] insert into hs_office_coopassetstransaction values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_office_coopassetstransaction_insert_permission_check_tg + before insert on hs_office_coopassetstransaction + for each row + execute procedure hs_office_coopassetstransaction_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_office_coopassetstransaction', + $idName$ + reference + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-coopassetstransaction-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_coopassetstransaction', + $orderBy$ + reference + $orderBy$, + $updates$ + comment = new.comment + $updates$); +--// + diff --git a/src/main/resources/db/changelog/326-hs-office-coopassets-migration.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5126-hs-office-coopassets-migration.sql similarity index 100% rename from src/main/resources/db/changelog/326-hs-office-coopassets-migration.sql rename to src/main/resources/db/changelog/5-hs-office/512-coopassets/5126-hs-office-coopassets-migration.sql diff --git a/src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql similarity index 59% rename from src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql rename to src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql index d54e77ca..b3cdab98 100644 --- a/src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql @@ -14,13 +14,9 @@ create or replace procedure createHsOfficeCoopAssetsTransactionTestData( ) language plpgsql as $$ declare - currentTask varchar; - membership hs_office_membership; + membership hs_office_membership; + lossEntryUuid uuid; begin - currentTask = 'creating coopAssetsTransaction test-data ' || givenPartnerNumber || givenMemberNumberSuffix; - execute format('set local hsadminng.currentTask to %L', currentTask); - - call defineContext(currentTask); select m.uuid from hs_office_membership m join hs_office_partner p on p.uuid = m.partneruuid @@ -29,12 +25,14 @@ begin into membership; raise notice 'creating test coopAssetsTransaction: %', givenPartnerNumber || givenMemberNumberSuffix; + lossEntryUuid := uuid_generate_v4(); insert - into hs_office_coopassetstransaction(uuid, membershipuuid, transactiontype, valuedate, assetvalue, reference, comment) + into hs_office_coopassetstransaction(uuid, membershipuuid, transactiontype, valuedate, assetvalue, reference, comment, adjustedAssetTxUuid) values - (uuid_generate_v4(), membership.uuid, 'DEPOSIT', '2010-03-15', 320.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-1', 'initial deposit'), - (uuid_generate_v4(), membership.uuid, 'DISBURSAL', '2021-09-01', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-2', 'partial disbursal'), - (uuid_generate_v4(), membership.uuid, 'ADJUSTMENT', '2022-10-20', 128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some adjustment'); + (uuid_generate_v4(), membership.uuid, 'DEPOSIT', '2010-03-15', 320.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-1', 'initial deposit', null), + (uuid_generate_v4(), membership.uuid, 'DISBURSAL', '2021-09-01', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-2', 'partial disbursal', null), + (lossEntryUuid, membership.uuid, 'DEPOSIT', '2022-10-20', 128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some loss', null), + (uuid_generate_v4(), membership.uuid, 'ADJUSTMENT', '2022-10-21', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some adjustment', lossEntryUuid); end; $$; --// @@ -45,6 +43,9 @@ end; $$; do language plpgsql $$ begin + call defineContext('creating coopAssetsTransaction test-data'); + SET CONSTRAINTS ALL DEFERRED; + call createHsOfficeCoopAssetsTransactionTestData(10001, '01'); call createHsOfficeCoopAssetsTransactionTestData(10002, '02'); call createHsOfficeCoopAssetsTransactionTestData(10003, '03'); diff --git a/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql b/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql new file mode 100644 index 00000000..72d9563f --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-booking-debitor-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create view hs_booking_debitor_xv as + select debitor.uuid, + debitor.version, + (partner.partnerNumber::varchar || debitor.debitorNumberSuffix)::numeric as debitorNumber, + debitor.defaultPrefix + from hs_office_debitor debitor + -- RBAC for debitor is sufficient, for faster access we are bypassing RBAC for the join tables + join hs_office_relation debitorRel on debitor.debitorReluUid=debitorRel.uuid + join hs_office_relation partnerRel on partnerRel.holderUuid=debitorRel.anchorUuid + join hs_office_partner partner on partner.partnerReluUid=partnerRel.uuid; +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql new file mode 100644 index 00000000..564e36c0 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql @@ -0,0 +1,29 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset booking-project-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table if not exists hs_booking_project +( + uuid uuid unique references RbacObject (uuid), + version int not null default 0, + debitorUuid uuid not null references hs_office_debitor(uuid), + caption varchar(80) not null +); +--// + + +-- ============================================================================ +--changeset hs-booking-project-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_booking_project'); +--// + + +-- ============================================================================ +--changeset hs-booking-project-MAIN-TABLE-HISTORIZATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call tx_create_historicization('hs_booking_project'); +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md new file mode 100644 index 00000000..7fb81cd7 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md @@ -0,0 +1,63 @@ +### rbac project + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph debitorRel["`**debitorRel**`"] + direction TB + style debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel:roles[ ] + style debitorRel:roles fill:#99bcdb,stroke:white + + role:debitorRel:OWNER[[debitorRel:OWNER]] + role:debitorRel:ADMIN[[debitorRel:ADMIN]] + role:debitorRel:AGENT[[debitorRel:AGENT]] + role:debitorRel:TENANT[[debitorRel:TENANT]] + end +end + +subgraph project["`**project**`"] + direction TB + style project fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph project:roles[ ] + style project:roles fill:#dd4901,stroke:white + + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] + end + + subgraph project:permissions[ ] + style project:permissions fill:#dd4901,stroke:white + + perm:project:INSERT{{project:INSERT}} + perm:project:DELETE{{project:DELETE}} + perm:project:UPDATE{{project:UPDATE}} + perm:project:SELECT{{project:SELECT}} + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel:OWNER -.-> role:debitorRel:ADMIN +role:debitorRel:ADMIN -.-> role:debitorRel:AGENT +role:debitorRel:AGENT -.-> role:debitorRel:TENANT +role:debitorRel:AGENT ==>|XX| role:project:OWNER +role:project:OWNER ==> role:project:ADMIN +role:project:ADMIN ==> role:project:AGENT +role:project:AGENT ==> role:project:TENANT +role:project:TENANT ==> role:debitorRel:TENANT + +%% granting permissions to roles +role:debitorRel:ADMIN ==> perm:project:INSERT +role:global:ADMIN ==> perm:project:DELETE +role:project:ADMIN ==> perm:project:UPDATE +role:project:TENANT ==> perm:project:SELECT + +``` diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql new file mode 100644 index 00000000..c6f3544d --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql @@ -0,0 +1,206 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-booking-project-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_booking_project'); +--// + + +-- ============================================================================ +--changeset hs-booking-project-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsBookingProject', 'hs_booking_project'); +--// + + +-- ============================================================================ +--changeset hs-booking-project-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsBookingProject( + NEW hs_booking_project +) + language plpgsql as $$ + +declare + newDebitor hs_office_debitor; + newDebitorRel hs_office_relation; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_office_debitor WHERE uuid = NEW.debitorUuid INTO newDebitor; + assert newDebitor.uuid is not null, format('newDebitor must not be null for NEW.debitorUuid = %s', NEW.debitorUuid); + + SELECT debitorRel.* + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = NEW.debitorUuid + INTO newDebitorRel; + assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s', NEW.debitorUuid); + + + perform createRoleWithGrants( + hsBookingProjectOWNER(NEW), + incomingSuperRoles => array[hsOfficeRelationAGENT(newDebitorRel, unassumed())] + ); + + perform createRoleWithGrants( + hsBookingProjectADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsBookingProjectOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsBookingProjectAGENT(NEW), + incomingSuperRoles => array[hsBookingProjectADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsBookingProjectTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsBookingProjectAGENT(NEW)], + outgoingSubRoles => array[hsOfficeRelationTENANT(newDebitorRel)] + ); + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin()); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_project row. + */ + +create or replace function insertTriggerForHsBookingProject_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsBookingProject(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsBookingProject_tg + after insert on hs_booking_project + for each row +execute procedure insertTriggerForHsBookingProject_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-project-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to hs_office_relation ---------------------------- + +/* + Grants INSERT INTO hs_booking_project permissions to specified role of pre-existing hs_office_relation rows. + */ +do language plpgsql $$ + declare + row hs_office_relation; + begin + call defineContext('create INSERT INTO hs_booking_project permissions for pre-exising hs_office_relation rows'); + + FOR row IN SELECT * FROM hs_office_relation + WHERE type = 'DEBITOR' + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_project'), + hsOfficeRelationADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_booking_project INSERT permission to specified role of new hs_office_relation rows. +*/ +create or replace function new_hs_booking_project_grants_insert_to_hs_office_relation_tf() + returns trigger + language plpgsql + strict as $$ +begin + if NEW.type = 'DEBITOR' then + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_project'), + hsOfficeRelationADMIN(NEW)); + end if; + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_project_grants_insert_to_hs_office_relation_tg + after insert on hs_office_relation + for each row +execute procedure new_hs_booking_project_grants_insert_to_hs_office_relation_tf(); + + +-- ============================================================================ +--changeset hs_booking_project-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_project. +*/ +create or replace function hs_booking_project_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT permission via indirect foreign key: NEW.debitorUuid + superObjectUuid := (SELECT debitorRel.uuid + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = NEW.debitorUuid + ); + assert superObjectUuid is not null, 'object uuid fetched depending on hs_booking_project.debitorUuid must not be null, also check fetchSql in RBAC DSL'; + if hasInsertPermission(superObjectUuid, 'hs_booking_project') then + return NEW; + end if; + + raise exception '[403] insert into hs_booking_project values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_booking_project_insert_permission_check_tg + before insert on hs_booking_project + for each row + execute procedure hs_booking_project_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-project-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromQuery('hs_booking_project', + $idName$ + SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingProject.caption) as idName + FROM hs_booking_project bookingProject + JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid + $idName$); +--// + + +-- ============================================================================ +--changeset hs-booking-project-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_booking_project', + $orderBy$ + caption + $orderBy$, + $updates$ + version = new.version, + caption = new.caption + $updates$); +--// + diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql new file mode 100644 index 00000000..2113ae5e --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql @@ -0,0 +1,49 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-booking-project-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single hs_booking_project test record. + */ +create or replace procedure createHsBookingProjectTransactionTestData( + givenPartnerNumber numeric, + givenDebitorSuffix char(2) + ) + language plpgsql as $$ +declare + relatedDebitor hs_office_debitor; +begin + + select debitor.* into relatedDebitor + from hs_office_debitor debitor + join hs_office_relation debitorRel on debitorRel.uuid = debitor.debitorRelUuid + join hs_office_relation partnerRel on partnerRel.holderUuid = debitorRel.anchorUuid + join hs_office_partner partner on partner.partnerRelUuid = partnerRel.uuid + where partner.partnerNumber = givenPartnerNumber and debitor.debitorNumberSuffix = givenDebitorSuffix; + + raise notice 'creating test booking-project: %', givenDebitorSuffix::text; + raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; + insert + into hs_booking_project (uuid, debitoruuid, caption) + values (uuid_generate_v4(), relatedDebitor.uuid, 'D-' || givenPartnerNumber::text || givenDebitorSuffix || ' default project'); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-booking-project-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsBookingProjectTransactionTestData(10001, '11'); + call createHsBookingProjectTransactionTestData(10002, '12'); + call createHsBookingProjectTransactionTestData(10003, '13'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql new file mode 100644 index 00000000..4796ac58 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql @@ -0,0 +1,47 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset booking-item-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create type HsBookingItemType as enum ( + 'PRIVATE_CLOUD', + 'CLOUD_SERVER', + 'MANAGED_SERVER', + 'MANAGED_WEBSPACE', + 'DOMAIN_SETUP' + ); + +CREATE CAST (character varying as HsBookingItemType) WITH INOUT AS IMPLICIT; + +create table if not exists hs_booking_item +( + uuid uuid unique references RbacObject (uuid), + version int not null default 0, + projectUuid uuid null references hs_booking_project(uuid), + type HsBookingItemType not null, + parentItemUuid uuid null references hs_booking_item(uuid) initially deferred, + validity daterange not null, + caption varchar(80) not null, + resources jsonb not null, + + constraint chk_hs_booking_item_has_project_or_parent_asset + check (projectUuid is not null or parentItemUuid is not null) +); +--// + + +-- ============================================================================ +--changeset hs-booking-item-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-MAIN-TABLE-HISTORIZATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call tx_create_historicization('hs_booking_item'); +--// + diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md new file mode 100644 index 00000000..4775616f --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md @@ -0,0 +1,63 @@ +### rbac bookingItem + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph bookingItem["`**bookingItem**`"] + direction TB + style bookingItem fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph bookingItem:roles[ ] + style bookingItem:roles fill:#dd4901,stroke:white + + role:bookingItem:OWNER[[bookingItem:OWNER]] + role:bookingItem:ADMIN[[bookingItem:ADMIN]] + role:bookingItem:AGENT[[bookingItem:AGENT]] + role:bookingItem:TENANT[[bookingItem:TENANT]] + end + + subgraph bookingItem:permissions[ ] + style bookingItem:permissions fill:#dd4901,stroke:white + + perm:bookingItem:INSERT{{bookingItem:INSERT}} + perm:bookingItem:DELETE{{bookingItem:DELETE}} + perm:bookingItem:UPDATE{{bookingItem:UPDATE}} + perm:bookingItem:SELECT{{bookingItem:SELECT}} + end +end + +subgraph project["`**project**`"] + direction TB + style project fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph project:roles[ ] + style project:roles fill:#99bcdb,stroke:white + + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] + end +end + +%% granting roles to roles +role:project:OWNER -.-> role:project:ADMIN +role:project:ADMIN -.-> role:project:AGENT +role:project:AGENT -.-> role:project:TENANT +role:project:AGENT ==> role:bookingItem:OWNER +role:bookingItem:OWNER ==> role:bookingItem:ADMIN +role:bookingItem:ADMIN ==> role:bookingItem:AGENT +role:bookingItem:AGENT ==> role:bookingItem:TENANT +role:bookingItem:TENANT ==> role:project:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:bookingItem:INSERT +role:global:ADMIN ==> perm:bookingItem:DELETE +role:project:ADMIN ==> perm:bookingItem:INSERT +role:bookingItem:ADMIN ==> perm:bookingItem:UPDATE +role:bookingItem:TENANT ==> perm:bookingItem:SELECT + +``` diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql new file mode 100644 index 00000000..bcd6523e --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql @@ -0,0 +1,277 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsBookingItem( + NEW hs_booking_item +) + language plpgsql as $$ + +declare + newProject hs_booking_project; + newParentItem hs_booking_item; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_booking_project WHERE uuid = NEW.projectUuid INTO newProject; + + SELECT * FROM hs_booking_item WHERE uuid = NEW.parentItemUuid INTO newParentItem; + + perform createRoleWithGrants( + hsBookingItemOWNER(NEW), + incomingSuperRoles => array[ + hsBookingItemAGENT(newParentItem), + hsBookingProjectAGENT(newProject)] + ); + + perform createRoleWithGrants( + hsBookingItemADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsBookingItemOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemAGENT(NEW), + incomingSuperRoles => array[hsBookingItemADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsBookingItemAGENT(NEW)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newParentItem), + hsBookingProjectTENANT(newProject)] + ); + + + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin()); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row. + */ + +create or replace function insertTriggerForHsBookingItem_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsBookingItem(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsBookingItem_tg + after insert on hs_booking_item + for each row +execute procedure insertTriggerForHsBookingItem_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_booking_item_grants_insert_to_global_tf(); + +-- granting INSERT permission to hs_booking_project ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_booking_project rows. + */ +do language plpgsql $$ + declare + row hs_booking_project; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_booking_project rows'); + + FOR row IN SELECT * FROM hs_booking_project + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_project rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_project_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_project_tg + after insert on hs_booking_project + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_project_tf(); + +-- granting INSERT permission to hs_booking_item ---------------------------- + +-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, +-- because there cannot yet be any pre-existing rows in the same table yet. + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_item rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_item_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingItemADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_item_tg + after insert on hs_booking_item + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_item_tf(); + + +-- ============================================================================ +--changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item. +*/ +create or replace function hs_booking_item_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.projectUuid + if hasInsertPermission(NEW.projectUuid, 'hs_booking_item') then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.parentItemUuid + if hasInsertPermission(NEW.parentItemUuid, 'hs_booking_item') then + return NEW; + end if; + + raise exception '[403] insert into hs_booking_item values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_booking_item_insert_permission_check_tg + before insert on hs_booking_item + for each row + execute procedure hs_booking_item_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_booking_item', + $idName$ + caption + $idName$); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_booking_item', + $orderBy$ + validity + $orderBy$, + $updates$ + version = new.version, + caption = new.caption, + validity = new.validity, + resources = new.resources + $updates$); +--// + diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql new file mode 100644 index 00000000..4052b5c3 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql @@ -0,0 +1,57 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-booking-item-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single hs_booking_item test record. + */ +create or replace procedure createHsBookingItemTransactionTestData( + givenPartnerNumber numeric, + givenDebitorSuffix char(2) + ) + language plpgsql as $$ +declare + relatedProject hs_booking_project; + privateCloudUuid uuid; + managedServerUuid uuid; +begin + select project.* into relatedProject + from hs_booking_project project + where project.caption = 'D-' || givenPartnerNumber || givenDebitorSuffix || ' default project'; + + raise notice 'creating test booking-item: %', givenPartnerNumber::text || givenDebitorSuffix::text; + raise notice '- using project (%): %', relatedProject.uuid, relatedProject; + privateCloudUuid := uuid_generate_v4(); + managedServerUuid := uuid_generate_v4(); + insert + into hs_booking_item (uuid, projectuuid, type, parentitemuuid, caption, validity, resources) + values (privateCloudUuid, relatedProject.uuid, 'PRIVATE_CLOUD', null, 'some PrivateCloud', daterange('20240401', null, '[]'), '{ "CPU": 10, "RAM": 32, "SSD": 4000, "HDD": 10000, "Traffic": 2000 }'::jsonb), + (uuid_generate_v4(), null, 'MANAGED_SERVER', privateCloudUuid, 'some ManagedServer', daterange('20230115', '20240415', '[)'), '{ "CPU": 2, "RAM": 4, "SSD": 500, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'CLOUD_SERVER', privateCloudUuid, 'test CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPU": 2, "RAM": 4, "SSD": 750, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'CLOUD_SERVER', privateCloudUuid, 'prod CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPU": 4, "RAM": 16, "SSD": 1000, "Traffic": 500 }'::jsonb), + (managedServerUuid, relatedProject.uuid, 'MANAGED_SERVER', null, 'separate ManagedServer', daterange('20221001', null, '[]'), '{ "CPU": 2, "RAM": 8, "SSD": 500, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'MANAGED_WEBSPACE', managedServerUuid, 'some ManagedWebspace', daterange('20221001', null, '[]'), '{ "SSD": 50, "Traffic": 20, "Daemons": 2, "Multi": 4 }'::jsonb), + (uuid_generate_v4(), relatedProject.uuid, 'MANAGED_WEBSPACE', null, 'separate ManagedWebspace', daterange('20221001', null, '[]'), '{ "SSD": 100, "Traffic": 50, "Daemons": 0, "Multi": 1 }'::jsonb); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-booking-item-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + declare + currentTask text; + begin + call defineContext('creating booking-item test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + call createHsBookingItemTransactionTestData(10001, '11'); + call createHsBookingItemTransactionTestData(10002, '12'); + call createHsBookingItemTransactionTestData(10003, '13'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md new file mode 100644 index 00000000..4775616f --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md @@ -0,0 +1,63 @@ +### rbac bookingItem + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph bookingItem["`**bookingItem**`"] + direction TB + style bookingItem fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph bookingItem:roles[ ] + style bookingItem:roles fill:#dd4901,stroke:white + + role:bookingItem:OWNER[[bookingItem:OWNER]] + role:bookingItem:ADMIN[[bookingItem:ADMIN]] + role:bookingItem:AGENT[[bookingItem:AGENT]] + role:bookingItem:TENANT[[bookingItem:TENANT]] + end + + subgraph bookingItem:permissions[ ] + style bookingItem:permissions fill:#dd4901,stroke:white + + perm:bookingItem:INSERT{{bookingItem:INSERT}} + perm:bookingItem:DELETE{{bookingItem:DELETE}} + perm:bookingItem:UPDATE{{bookingItem:UPDATE}} + perm:bookingItem:SELECT{{bookingItem:SELECT}} + end +end + +subgraph project["`**project**`"] + direction TB + style project fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph project:roles[ ] + style project:roles fill:#99bcdb,stroke:white + + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] + end +end + +%% granting roles to roles +role:project:OWNER -.-> role:project:ADMIN +role:project:ADMIN -.-> role:project:AGENT +role:project:AGENT -.-> role:project:TENANT +role:project:AGENT ==> role:bookingItem:OWNER +role:bookingItem:OWNER ==> role:bookingItem:ADMIN +role:bookingItem:ADMIN ==> role:bookingItem:AGENT +role:bookingItem:AGENT ==> role:bookingItem:TENANT +role:bookingItem:TENANT ==> role:project:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:bookingItem:INSERT +role:global:ADMIN ==> perm:bookingItem:DELETE +role:project:ADMIN ==> perm:bookingItem:INSERT +role:bookingItem:ADMIN ==> perm:bookingItem:UPDATE +role:bookingItem:TENANT ==> perm:bookingItem:SELECT + +``` diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql new file mode 100644 index 00000000..bcd6523e --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql @@ -0,0 +1,277 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsBookingItem( + NEW hs_booking_item +) + language plpgsql as $$ + +declare + newProject hs_booking_project; + newParentItem hs_booking_item; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_booking_project WHERE uuid = NEW.projectUuid INTO newProject; + + SELECT * FROM hs_booking_item WHERE uuid = NEW.parentItemUuid INTO newParentItem; + + perform createRoleWithGrants( + hsBookingItemOWNER(NEW), + incomingSuperRoles => array[ + hsBookingItemAGENT(newParentItem), + hsBookingProjectAGENT(newProject)] + ); + + perform createRoleWithGrants( + hsBookingItemADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsBookingItemOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemAGENT(NEW), + incomingSuperRoles => array[hsBookingItemADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsBookingItemAGENT(NEW)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newParentItem), + hsBookingProjectTENANT(newProject)] + ); + + + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin()); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row. + */ + +create or replace function insertTriggerForHsBookingItem_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsBookingItem(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsBookingItem_tg + after insert on hs_booking_item + for each row +execute procedure insertTriggerForHsBookingItem_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_booking_item_grants_insert_to_global_tf(); + +-- granting INSERT permission to hs_booking_project ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_booking_project rows. + */ +do language plpgsql $$ + declare + row hs_booking_project; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_booking_project rows'); + + FOR row IN SELECT * FROM hs_booking_project + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_project rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_project_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_project_tg + after insert on hs_booking_project + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_project_tf(); + +-- granting INSERT permission to hs_booking_item ---------------------------- + +-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, +-- because there cannot yet be any pre-existing rows in the same table yet. + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_item rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_item_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingItemADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_item_tg + after insert on hs_booking_item + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_item_tf(); + + +-- ============================================================================ +--changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item. +*/ +create or replace function hs_booking_item_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.projectUuid + if hasInsertPermission(NEW.projectUuid, 'hs_booking_item') then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.parentItemUuid + if hasInsertPermission(NEW.parentItemUuid, 'hs_booking_item') then + return NEW; + end if; + + raise exception '[403] insert into hs_booking_item values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_booking_item_insert_permission_check_tg + before insert on hs_booking_item + for each row + execute procedure hs_booking_item_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_booking_item', + $idName$ + caption + $idName$); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_booking_item', + $orderBy$ + validity + $orderBy$, + $updates$ + version = new.version, + caption = new.caption, + validity = new.validity, + resources = new.resources + $updates$); +--// + diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql new file mode 100644 index 00000000..83d6cacb --- /dev/null +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql @@ -0,0 +1,179 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hosting-asset-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create type HsHostingAssetType as enum ( + 'CLOUD_SERVER', + 'MANAGED_SERVER', + 'MANAGED_WEBSPACE', + 'UNIX_USER', + 'DOMAIN_SETUP', + 'DOMAIN_DNS_SETUP', + 'DOMAIN_HTTP_SETUP', + 'DOMAIN_SMTP_SETUP', + 'DOMAIN_MBOX_SETUP', + 'EMAIL_ALIAS', + 'EMAIL_ADDRESS', + 'PGSQL_INSTANCE', + 'PGSQL_USER', + 'PGSQL_DATABASE', + 'MARIADB_INSTANCE', + 'MARIADB_USER', + 'MARIADB_DATABASE', + 'IPV4_NUMBER', + 'IPV6_NUMBER' +); + +CREATE CAST (character varying as HsHostingAssetType) WITH INOUT AS IMPLICIT; + +create table if not exists hs_hosting_asset +( + uuid uuid unique references RbacObject (uuid), + version int not null default 0, + bookingItemUuid uuid null references hs_booking_item(uuid), + type HsHostingAssetType not null, + parentAssetUuid uuid null references hs_hosting_asset(uuid) initially deferred, + assignedToAssetUuid uuid null references hs_hosting_asset(uuid) initially deferred, + identifier varchar(80) not null, + caption varchar(80), + config jsonb not null, + alarmContactUuid uuid null references hs_office_contact(uuid) initially deferred, + + constraint chk_hs_hosting_asset_has_booking_item_or_parent_asset + check (bookingItemUuid is not null or parentAssetUuid is not null or type in ('DOMAIN_SETUP', 'IPV4_NUMBER', 'IPV6_NUMBER')) +); +--// + + +-- ============================================================================ +--changeset hosting-asset-TYPE-HIERARCHY-CHECK:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- TODO.impl: this could be generated from HsHostingAssetType +-- also including a check for assignedToAssetUuud + +create or replace function hs_hosting_asset_type_hierarchy_check_tf() + returns trigger + language plpgsql as $$ +declare + actualParentType HsHostingAssetType; + expectedParentType HsHostingAssetType; +begin + if NEW.parentAssetUuid is not null then + actualParentType := (select type + from hs_hosting_asset + where NEW.parentAssetUuid = uuid); + end if; + + expectedParentType := (select case NEW.type + when 'CLOUD_SERVER' then null + when 'MANAGED_SERVER' then null + when 'MANAGED_WEBSPACE' then 'MANAGED_SERVER' + when 'UNIX_USER' then 'MANAGED_WEBSPACE' + when 'EMAIL_ALIAS' then 'MANAGED_WEBSPACE' + when 'DOMAIN_SETUP' then null + when 'DOMAIN_DNS_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_HTTP_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_SMTP_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_MBOX_SETUP' then 'DOMAIN_SETUP' + when 'EMAIL_ADDRESS' then 'DOMAIN_MBOX_SETUP' + + when 'PGSQL_INSTANCE' then 'MANAGED_SERVER' + when 'PGSQL_USER' then 'MANAGED_WEBSPACE' + when 'PGSQL_DATABASE' then 'PGSQL_USER' + + when 'MARIADB_INSTANCE' then 'MANAGED_SERVER' + when 'MARIADB_USER' then 'MANAGED_WEBSPACE' + when 'MARIADB_DATABASE' then 'MARIADB_USER' + + when 'IPV4_NUMBER' then null + when 'IPV6_NUMBER' then null + + else raiseException(format('[400] unknown asset type %s', NEW.type::text)) + end); + + if expectedParentType is not null and actualParentType is null then + raise exception '[400] HostingAsset % must have % as parent, but got ', + NEW.type, expectedParentType; + elsif expectedParentType is not null and actualParentType <> expectedParentType then + raise exception '[400] HostingAsset % must have % as parent, but got %s', + NEW.type, expectedParentType, actualParentType; + end if; + return NEW; +end; $$; + +create trigger hs_hosting_asset_type_hierarchy_check_tg + before insert on hs_hosting_asset + for each row + execute procedure hs_hosting_asset_type_hierarchy_check_tf(); +--// + + + +-- ============================================================================ +--changeset hosting-asset-system-sequences:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE SEQUENCE IF NOT EXISTS hs_hosting_asset_unixuser_system_id_seq + AS integer + MINVALUE 1000000 + MAXVALUE 9999999 + NO CYCLE + OWNED BY NONE; + +--// + + +-- ============================================================================ +--changeset hosting-asset-BOOKING-ITEM-HIERARCHY-CHECK:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace function hs_hosting_asset_booking_item_hierarchy_check_tf() + returns trigger + language plpgsql as $$ +declare + actualBookingItemType HsBookingItemType; + expectedBookingItemType HsBookingItemType; +begin + actualBookingItemType := (select type + from hs_booking_item + where NEW.bookingItemUuid = uuid); + + if NEW.type = 'CLOUD_SERVER' then + expectedBookingItemType := 'CLOUD_SERVER'; + elsif NEW.type = 'MANAGED_SERVER' then + expectedBookingItemType := 'MANAGED_SERVER'; + elsif NEW.type = 'MANAGED_WEBSPACE' then + expectedBookingItemType := 'MANAGED_WEBSPACE'; + end if; + + if not actualBookingItemType = expectedBookingItemType then + raise exception '[400] HostingAsset % % must have % as booking-item, but got %', + NEW.type, NEW.identifier, expectedBookingItemType, actualBookingItemType; + end if; + return NEW; +end; $$; + +create trigger hs_hosting_asset_booking_item_hierarchy_check_tg + before insert on hs_hosting_asset + for each row +execute procedure hs_hosting_asset_booking_item_hierarchy_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call create_journal('hs_hosting_asset'); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-MAIN-TABLE-HISTORIZATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call tx_create_historicization('hs_hosting_asset'); +--// + + diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md new file mode 100644 index 00000000..d06f9f9a --- /dev/null +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md @@ -0,0 +1,117 @@ +### rbac asset + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph alarmContact["`**alarmContact**`"] + direction TB + style alarmContact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph alarmContact:roles[ ] + style alarmContact:roles fill:#99bcdb,stroke:white + + role:alarmContact:OWNER[[alarmContact:OWNER]] + role:alarmContact:ADMIN[[alarmContact:ADMIN]] + role:alarmContact:REFERRER[[alarmContact:REFERRER]] + end +end + +subgraph asset["`**asset**`"] + direction TB + style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph asset:roles[ ] + style asset:roles fill:#dd4901,stroke:white + + role:asset:OWNER[[asset:OWNER]] + role:asset:ADMIN[[asset:ADMIN]] + role:asset:AGENT[[asset:AGENT]] + role:asset:TENANT[[asset:TENANT]] + end + + subgraph asset:permissions[ ] + style asset:permissions fill:#dd4901,stroke:white + + perm:asset:INSERT{{asset:INSERT}} + perm:asset:DELETE{{asset:DELETE}} + perm:asset:UPDATE{{asset:UPDATE}} + perm:asset:SELECT{{asset:SELECT}} + end +end + +subgraph assignedToAsset["`**assignedToAsset**`"] + direction TB + style assignedToAsset fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph assignedToAsset:roles[ ] + style assignedToAsset:roles fill:#99bcdb,stroke:white + + role:assignedToAsset:AGENT[[assignedToAsset:AGENT]] + role:assignedToAsset:TENANT[[assignedToAsset:TENANT]] + end +end + +subgraph bookingItem["`**bookingItem**`"] + direction TB + style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph bookingItem:roles[ ] + style bookingItem:roles fill:#99bcdb,stroke:white + + role:bookingItem:OWNER[[bookingItem:OWNER]] + role:bookingItem:ADMIN[[bookingItem:ADMIN]] + role:bookingItem:AGENT[[bookingItem:AGENT]] + role:bookingItem:TENANT[[bookingItem:TENANT]] + end +end + +subgraph parentAsset["`**parentAsset**`"] + direction TB + style parentAsset fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph parentAsset:roles[ ] + style parentAsset:roles fill:#99bcdb,stroke:white + + role:parentAsset:ADMIN[[parentAsset:ADMIN]] + role:parentAsset:AGENT[[parentAsset:AGENT]] + role:parentAsset:TENANT[[parentAsset:TENANT]] + end +end + +%% granting roles to users +user:creator ==> role:asset:OWNER + +%% granting roles to roles +role:bookingItem:OWNER -.-> role:bookingItem:ADMIN +role:bookingItem:ADMIN -.-> role:bookingItem:AGENT +role:bookingItem:AGENT -.-> role:bookingItem:TENANT +role:global:ADMIN -.-> role:alarmContact:OWNER +role:alarmContact:OWNER -.-> role:alarmContact:ADMIN +role:alarmContact:ADMIN -.-> role:alarmContact:REFERRER +role:global:ADMIN ==>|XX| role:asset:OWNER +role:bookingItem:ADMIN ==> role:asset:OWNER +role:parentAsset:ADMIN ==> role:asset:OWNER +role:asset:OWNER ==> role:asset:ADMIN +role:bookingItem:AGENT ==> role:asset:ADMIN +role:parentAsset:AGENT ==> role:asset:ADMIN +role:asset:ADMIN ==> role:asset:AGENT +role:assignedToAsset:AGENT ==> role:asset:AGENT +role:asset:AGENT ==> role:assignedToAsset:TENANT +role:asset:AGENT ==> role:alarmContact:REFERRER +role:asset:AGENT ==> role:asset:TENANT +role:asset:TENANT ==> role:bookingItem:TENANT +role:asset:TENANT ==> role:parentAsset:TENANT +role:alarmContact:ADMIN ==> role:asset:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:asset:INSERT +role:parentAsset:ADMIN ==> perm:asset:INSERT +role:global:GUEST ==> perm:asset:INSERT +role:asset:OWNER ==> perm:asset:DELETE +role:asset:ADMIN ==> perm:asset:UPDATE +role:asset:TENANT ==> perm:asset:SELECT + +``` diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql new file mode 100644 index 00000000..5ec3e044 --- /dev/null +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql @@ -0,0 +1,183 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_hosting_asset'); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsHostingAsset', 'hs_hosting_asset'); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsHostingAsset( + NEW hs_hosting_asset +) + language plpgsql as $$ + +declare + newBookingItem hs_booking_item; + newAssignedToAsset hs_hosting_asset; + newAlarmContact hs_office_contact; + newParentAsset hs_hosting_asset; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_booking_item WHERE uuid = NEW.bookingItemUuid INTO newBookingItem; + + SELECT * FROM hs_hosting_asset WHERE uuid = NEW.assignedToAssetUuid INTO newAssignedToAsset; + + SELECT * FROM hs_office_contact WHERE uuid = NEW.alarmContactUuid INTO newAlarmContact; + + SELECT * FROM hs_hosting_asset WHERE uuid = NEW.parentAssetUuid INTO newParentAsset; + + perform createRoleWithGrants( + hsHostingAssetOWNER(NEW), + permissions => array['DELETE'], + incomingSuperRoles => array[ + globalADMIN(unassumed()), + hsBookingItemADMIN(newBookingItem), + hsHostingAssetADMIN(newParentAsset)], + userUuids => array[currentUserUuid()] + ); + + perform createRoleWithGrants( + hsHostingAssetADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[ + hsBookingItemAGENT(newBookingItem), + hsHostingAssetAGENT(newParentAsset), + hsHostingAssetOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsHostingAssetAGENT(NEW), + incomingSuperRoles => array[ + hsHostingAssetADMIN(NEW), + hsHostingAssetAGENT(newAssignedToAsset)], + outgoingSubRoles => array[ + hsHostingAssetTENANT(newAssignedToAsset), + hsOfficeContactREFERRER(newAlarmContact)] + ); + + perform createRoleWithGrants( + hsHostingAssetTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[ + hsHostingAssetAGENT(NEW), + hsOfficeContactADMIN(newAlarmContact)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newBookingItem), + hsHostingAssetTENANT(newParentAsset)] + ); + + IF NEW.type = 'DOMAIN_SETUP' THEN + END IF; + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_hosting_asset row. + */ + +create or replace function insertTriggerForHsHostingAsset_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsHostingAsset(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsHostingAsset_tg + after insert on hs_hosting_asset + for each row +execute procedure insertTriggerForHsHostingAsset_tf(); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-update-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Called from the AFTER UPDATE TRIGGER to re-wire the grants. + */ + +create or replace procedure updateRbacRulesForHsHostingAsset( + OLD hs_hosting_asset, + NEW hs_hosting_asset +) + language plpgsql as $$ +begin + + if NEW.assignedToAssetUuid is distinct from OLD.assignedToAssetUuid + or NEW.alarmContactUuid is distinct from OLD.alarmContactUuid then + delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid; + call buildRbacSystemForHsHostingAsset(NEW); + end if; +end; $$; + +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_hosting_asset row. + */ + +create or replace function updateTriggerForHsHostingAsset_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForHsHostingAsset(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForHsHostingAsset_tg + after update on hs_hosting_asset + for each row +execute procedure updateTriggerForHsHostingAsset_tf(); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_hosting_asset', + $idName$ + identifier + $idName$); +--// + + +-- ============================================================================ +--changeset hs-hosting-asset-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_hosting_asset', + $orderBy$ + identifier + $orderBy$, + $updates$ + version = new.version, + caption = new.caption, + config = new.config, + assignedToAssetUuid = new.assignedToAssetUuid, + alarmContactUuid = new.alarmContactUuid + $updates$); +--// + diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql new file mode 100644 index 00000000..0af7e38e --- /dev/null +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql @@ -0,0 +1,120 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-hosting-asset-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single hs_hosting_asset test record. + */ +create or replace procedure createHsHostingAssetTestData(givenProjectCaption varchar) + language plpgsql as $$ +declare + relatedProject hs_booking_project; + relatedDebitor hs_office_debitor; + privateCloudBI hs_booking_item; + managedServerBI hs_booking_item; + cloudServerBI hs_booking_item; + managedWebspaceBI hs_booking_item; + debitorNumberSuffix varchar; + defaultPrefix varchar; + managedServerUuid uuid; + managedWebspaceUuid uuid; + webUnixUserUuid uuid; + mboxUnixUserUuid uuid; + domainSetupUuid uuid; + domainMBoxSetupUuid uuid; + mariaDbInstanceUuid uuid; + mariaDbUserUuid uuid; + pgSqlInstanceUuid uuid; + PgSqlUserUuid uuid; +begin + call defineContext('creating hosting-asset test-data', null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + + select project.* into relatedProject + from hs_booking_project project + where project.caption = givenProjectCaption; + assert relatedProject.uuid is not null, 'relatedProject for "' || givenProjectCaption || '" must not be null'; + + select debitor.* into relatedDebitor + from hs_office_debitor debitor + where debitor.uuid = relatedProject.debitorUuid; + assert relatedDebitor.uuid is not null, 'relatedDebitor for "' || givenProjectCaption || '" must not be null'; + + select item.* into privateCloudBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'PRIVATE_CLOUD'; + assert privateCloudBI.uuid is not null, 'relatedPrivateCloudBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into managedServerBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'MANAGED_SERVER'; + assert managedServerBI.uuid is not null, 'relatedManagedServerBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into cloudServerBI + from hs_booking_item item + where item.parentItemuuid = privateCloudBI.uuid + and item.type = 'CLOUD_SERVER'; + assert cloudServerBI.uuid is not null, 'relatedCloudServerBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into managedWebspaceBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'MANAGED_WEBSPACE'; + assert managedWebspaceBI.uuid is not null, 'relatedManagedWebspaceBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select uuid_generate_v4() into managedServerUuid; + select uuid_generate_v4() into managedWebspaceUuid; + select uuid_generate_v4() into webUnixUserUuid; + select uuid_generate_v4() into mboxUnixUserUuid; + select uuid_generate_v4() into domainSetupUuid; + select uuid_generate_v4() into domainMBoxSetupUuid; + select uuid_generate_v4() into mariaDbInstanceUuid; + select uuid_generate_v4() into mariaDbUserUuid; + select uuid_generate_v4() into pgSqlInstanceUuid; + select uuid_generate_v4() into pgSqlUserUuid; + debitorNumberSuffix := relatedDebitor.debitorNumberSuffix; + defaultPrefix := relatedDebitor.defaultPrefix; + + insert into hs_hosting_asset + (uuid, bookingitemuuid, type, parentAssetUuid, assignedToAssetUuid, identifier, caption, config) + values + (managedServerUuid, managedServerBI.uuid, 'MANAGED_SERVER', null, null, 'vm10' || debitorNumberSuffix, 'some ManagedServer', '{ "monit_max_cpu_usage": 90, "monit_max_ram_usage": 80, "monit_max_ssd_usage": 70 }'::jsonb), + (uuid_generate_v4(), cloudServerBI.uuid, 'CLOUD_SERVER', null, null, 'vm20' || debitorNumberSuffix, 'another CloudServer', '{}'::jsonb), + (managedWebspaceUuid, managedWebspaceBI.uuid, 'MANAGED_WEBSPACE', managedServerUuid, null, defaultPrefix || '01', 'some Webspace', '{}'::jsonb), + (mariaDbInstanceUuid, null, 'MARIADB_INSTANCE', managedServerUuid, null, 'vm10' || debitorNumberSuffix || '.MariaDB.default', 'some default MariaDB instance','{}'::jsonb), + (mariaDbUserUuid, null, 'MARIADB_USER', managedWebspaceUuid, mariaDbInstanceUuid, defaultPrefix || '01_web', 'some default MariaDB user', '{ "password": " hasStaticMethodNamed(final String expectedName) { + return new DescribedPredicate<>("rbac entity") { + @Override + public boolean test(final JavaMethod method) { + return method.getModifiers().contains(JavaModifier.STATIC) && method.getName().equals(expectedName); + } + }; } - @Test - public void repositoryNaming() { - classes().that().implement(JpaRepository.class).should().haveSimpleNameEndingWith("Repository"); + static ArchCondition haveTableNameEndingWith_rv() { + return new ArchCondition<>("RBAC table name end with _rv") { + + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + final var table = javaClass.getAnnotationOfType(Table.class); + if (table == null) { + events.add(SimpleConditionEvent.violated(javaClass, + format("@Table annotation missing for RBAC entity %s", + javaClass.getName(), table.name()))); + } else if (!table.name().endsWith("_rv")) { + events.add(SimpleConditionEvent.violated(javaClass, + format("Table name of %s does not end with '_rv' for RBAC entity %s", + javaClass.getName(), table.name()))); + } + } + }; } } diff --git a/src/test/java/net/hostsharing/hsadminng/context/ContextBasedTest.java b/src/test/java/net/hostsharing/hsadminng/context/ContextBasedTest.java deleted file mode 100644 index 828097e9..00000000 --- a/src/test/java/net/hostsharing/hsadminng/context/ContextBasedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.hostsharing.hsadminng.context; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.TestInfo; -import org.springframework.beans.factory.annotation.Autowired; - -public abstract class ContextBasedTest { - - @Autowired - Context context; - - TestInfo test; - - @BeforeEach - void init(TestInfo testInfo) { - this.test = testInfo; - } - - protected void context(final String currentUser, final String assumedRoles) { - context.define(test.getDisplayName(), null, currentUser, assumedRoles); - } - - protected void context(final String currentUser) { - context(currentUser, null); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java index ad3cdfa0..e54eac1e 100644 --- a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java @@ -40,7 +40,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(409); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("First Line"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [409] First Line"); } @Test @@ -59,7 +59,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); assertThat(errorResponse.getBody()).isNotNull() - .extracting(CustomErrorResponse::getMessage).isEqualTo("Second Line"); + .extracting(CustomErrorResponse::getMessage).isEqualTo("ERROR: [400] Second Line"); } @Test @@ -91,7 +91,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find Partner with uuid 12345-123454"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [400] Unable to find Partner with uuid 12345-123454"); } @Test @@ -109,7 +109,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); assertThat(errorResponse.getBody().getMessage()).isEqualTo( - "Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454"); + "ERROR: [400] Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454"); } @Test @@ -125,7 +125,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("whatever error message"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [400] whatever error message"); } @Test @@ -143,7 +143,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find NoDisplayNameEntity with uuid 12345-123454"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [400] Unable to find NoDisplayNameEntity with uuid 12345-123454"); } @Test @@ -172,7 +172,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(404); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("some error message"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [404] some error message"); } @ParameterizedTest @@ -187,11 +187,11 @@ class RestResponseEntityExceptionHandlerUnitTest { final var givenWebRequest = mock(WebRequest.class); // when - final var errorResponse = exceptionHandler.handleIbanAndBicExceptions(givenException, givenWebRequest); + final var errorResponse = exceptionHandler.handleValidationExceptions(givenException, givenWebRequest); // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("given error message"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [400] given error message"); } @Test @@ -218,7 +218,8 @@ class RestResponseEntityExceptionHandlerUnitTest { .extracting("statusCode").isEqualTo(400); assertThat(errorResponse.getBody()) .extracting("message") - .isEqualTo("[someField expected to be something but is \"someRejectedValue\"]"); + // FYI: the brackets around the message are here because it's actually an array, in this case of size 1 + .isEqualTo("ERROR: [400] [someField expected to be something but is \"someRejectedValue\"]"); } @Test @@ -232,7 +233,7 @@ class RestResponseEntityExceptionHandlerUnitTest { // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(500); - assertThat(errorResponse.getBody().getMessage()).isEqualTo("First Line"); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [500] First Line"); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java new file mode 100644 index 00000000..fcb2ce3d --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java @@ -0,0 +1,90 @@ +package net.hostsharing.hsadminng.hash; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.Charset; +import java.util.Base64; + +import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_SHA512; +import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_YESCRYPT; +import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.MYSQL_NATIVE; +import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.SCRAM_SHA256; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HashGeneratorUnitTest { + + final String GIVEN_PASSWORD = "given password"; + final String WRONG_PASSWORD = "wrong password"; + final String GIVEN_SALT = "0123456789abcdef"; + + // generated via mkpasswd for plaintext password GIVEN_PASSWORD (see above) + final String GIVEN_LINUX_GENERATED_SHA512_HASH = "$6$ooei1HK6JXVaI7KC$sY5d9fEOr36hjh4CYwIKLMfRKL1539bEmbVCZ.zPiH0sv7jJVnoIXb5YEefEtoSM2WWgDi9hr7vXRe3Nw8zJP/"; + final String GIVEN_LINUX_GENERATED_YESCRYPT_HASH = "$y$j9T$wgYACPmBXvlMg2MzeZA0p1$KXUzd28nG.67GhPnBZ3aZsNNA5bWFdL/dyG4wS0iRw7"; + + // generated in PostgreSQL using: + // CREATE USER test WITH PASSWORD 'given password'; + // SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'test'; + final String GIVEN_POSTGRESQL_GENERATED_SCRAM_SHA256_HASH = "SCRAM-SHA-256$4096:m8M12fdSTsKH+ywthTx1Zw==$4vsB1OddRNdsej9NPAFh91MPdtbOPjkQ85LQZS5lV0Q=:NsVpQNx4Ic/8Sqj1dxfBzUAxyF4FCTMpIsI+bOZCTfA="; + + @Test + void verifiesLinuxPasswordAgainstSha512HashFromMkpasswd() { + LinuxEtcShadowHashGenerator.verify(GIVEN_LINUX_GENERATED_SHA512_HASH, GIVEN_PASSWORD); // throws exception if wrong + } + + @Test + void verifiesLinuxPasswordAgainstYescryptHashFromMkpasswd() { + LinuxEtcShadowHashGenerator.verify(GIVEN_LINUX_GENERATED_YESCRYPT_HASH, GIVEN_PASSWORD); // throws exception if wrong + } + + @Test + void verifiesHashedLinuxPasswordWithRandomSalt() { + final var hash = HashGenerator.using(LINUX_SHA512).withRandomSalt().hash(GIVEN_PASSWORD); + LinuxEtcShadowHashGenerator.verify(hash, GIVEN_PASSWORD); // throws exception if wrong + } + + @Test + void verifiesLinuxHashedPasswordWithGivenSalt() { + final var hash = HashGenerator.using(LINUX_SHA512).withSalt(GIVEN_SALT).hash(GIVEN_PASSWORD); + LinuxEtcShadowHashGenerator.verify(hash, GIVEN_PASSWORD); // throws exception if wrong + } + + @Test + void throwsExceptionForInvalidLinuxPassword() { + final var hash = HashGenerator.using(LINUX_SHA512).withRandomSalt().hash(GIVEN_PASSWORD); + final var throwable = catchThrowable(() -> + LinuxEtcShadowHashGenerator.verify(hash, WRONG_PASSWORD) + ); + assertThat(throwable).hasMessage("invalid password"); + } + + @Test + void generatesLinuxSha512PasswordHash() { + final var hash = HashGenerator.using(LINUX_SHA512).withSalt("ooei1HK6JXVaI7KC").hash(GIVEN_PASSWORD); + assertThat(hash).isEqualTo(GIVEN_LINUX_GENERATED_SHA512_HASH); + } + + @Test + void generatesLinuxYescriptPasswordHash() { + final var hash = HashGenerator.using(LINUX_YESCRYPT).withSalt("wgYACPmBXvlMg2MzeZA0p1").hash(GIVEN_PASSWORD); + assertThat(hash).isEqualTo(GIVEN_LINUX_GENERATED_YESCRYPT_HASH); + } + + @Test + void generatesMySqlNativePasswordHash() { + final var hash = HashGenerator.using(MYSQL_NATIVE).hash("Test1234"); + assertThat(hash).isEqualTo("*14F1A8C42F8B6D4662BB3ED290FD37BF135FE45C"); + } + + @Test + void generatePostgreSqlScramPasswordHash() { + // given the same salt, extracted from the hash as generated by PostgreSQL + final var postgresBase64Salt = Base64.getDecoder().decode(GIVEN_POSTGRESQL_GENERATED_SCRAM_SHA256_HASH.split("\\$")[1].split(":")[1]); + + // when the hash is re-generated via Java + final var hash = HashGenerator.using(SCRAM_SHA256).withSalt(new String(postgresBase64Salt, Charset.forName("latin1"))).hash(GIVEN_PASSWORD); + + // then we are getting the same hash + assertThat(hash).isEqualTo(GIVEN_POSTGRESQL_GENERATED_SCRAM_SHA256_HASH); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java new file mode 100644 index 00000000..154e2b89 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java @@ -0,0 +1,33 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsBookingDebitorEntityUnitTest { + + @Test + void toStringContainsDebitorNumberAndDefaultPrefix() { + final var given = HsBookingDebitorEntity.builder() + .debitorNumber(1234567) + .defaultPrefix("som") + .build(); + + final var result = given.toString(); + + assertThat(result).isEqualTo("booking-debitor(D-1234567: som)"); + } + + @Test + void toShortStringContainsDefaultPrefix() { + final var given = HsBookingDebitorEntity.builder() + .debitorNumber(1234567) + .defaultPrefix("som") + .build(); + + final var result = given.toShortString(); + + assertThat(result).isEqualTo("D-1234567"); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java new file mode 100644 index 00000000..2dcc6c3b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java @@ -0,0 +1,13 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import lombok.experimental.UtilityClass; + + +@UtilityClass +public class TestHsBookingDebitor { + + public static final HsBookingDebitorEntity TEST_BOOKING_DEBITOR = HsBookingDebitorEntity.builder() + .debitorNumber(1234500) + .defaultPrefix("abc") + .build(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java new file mode 100644 index 00000000..539df3e5 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java @@ -0,0 +1,408 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import io.hypersistence.utils.hibernate.type.range.Range; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static java.util.Map.entry; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.matchesRegex; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +@TestClassOrder(ClassOrderer.OrderAnnotation.class) // fail early on fetching problems +class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + @LocalServerPort + private Integer port; + + @Autowired + HsBookingItemRealRepository realBookingItemRepo; + + @Autowired + HsBookingProjectRealRepository realProjectRepo; + + @Autowired + HsOfficeDebitorRepository debitorRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @Nested + @Order(2) + class ListBookingItems { + + @Test + void globalAdmin_canViewAllBookingItemsOfArbitraryDebitor() { + + // given + context("superuser-alex@hostsharing.net"); + final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() + .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findFirst() + .orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/items?projectUuid=" + givenProject.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "type": "MANAGED_WEBSPACE", + "caption": "separate ManagedWebspace", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "SSD": 100, + "Multi": 1, + "Daemons": 0, + "Traffic": 50 + } + }, + { + "type": "MANAGED_SERVER", + "caption": "separate ManagedServer", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "RAM": 8, + "SSD": 500, + "CPU": 2, + "Traffic": 500 + } + }, + { + "type": "PRIVATE_CLOUD", + "caption": "some PrivateCloud", + "validFrom": "2024-04-01", + "validTo": null, + "resources": { + "HDD": 10000, + "RAM": 32, + "SSD": 4000, + "CPU": 10, + "Traffic": 2000 + } + } + ] + """)); + // @formatter:on + } + } + + @Nested + @Order(3) + class AddBookingItem { + + @Test + void globalAdmin_canAddBookingItem() { + + context.define("superuser-alex@hostsharing.net"); + final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() + .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findFirst() + .orElseThrow(); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "projectUuid": "{projectUuid}", + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validTo": "{validTo}", + "resources": { "CPU": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{projectUuid}", givenProject.getUuid().toString()) + .replace("{validTo}", LocalDate.now().plusMonths(1).toString()) + ) + .port(port) + .when() + .post("http://localhost/api/hs/booking/items") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{today}", + "validTo": "{todayPlus1Month}", + "resources": { "CPU": 12, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + ) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/items/[^/]*")) + .extract().header("Location"); // @formatter:on + + // finally, the new bookingItem can be accessed under the generated UUID + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newUserUuid).isNotNull(); + } + } + + @Nested + @Order(1) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class GetBookingItem { + + @Test + @Order(1) + void globalAdmin_canGetArbitraryBookingItem() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItemUuid = realBookingItemRepo.findByCaption("separate ManagedWebspace").stream() + .filter(bi -> belongsToProject(bi, "D-1000111 default project")) + .map(HsBookingItem::getUuid) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "type": "MANAGED_WEBSPACE", + "caption": "separate ManagedWebspace", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "SSD": 100, + "Multi": 1, + "Daemons": 0, + "Traffic": 50 + } + } + """)); // @formatter:on + } + + @Test + @Order(2) + void normalUser_canNotGetUnrelatedBookingItem() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItemUuid = realBookingItemRepo.findByCaption("separate ManagedServer").stream() + .filter(bi -> belongsToProject(bi, "D-1000212 default project")) + .map(HsBookingItem::getUuid) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + @Order(3) + void projectAdmin_canGetRelatedBookingItem() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItem = realBookingItemRepo.findByCaption("separate ManagedServer").stream() + .filter(bi -> belongsToProject(bi, "D-1000313 default project")) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_booking_project#D-1000313-D-1000313defaultproject:ADMIN") + .port(port) + .when() + .get("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "separate ManagedServer", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "RAM": 8, + "SSD": 500, + "CPU": 2, + "Traffic": 500 + } + } + """)); // @formatter:on + } + + private static boolean belongsToProject(final HsBookingItem bi, final String projectCaption) { + return ofNullable(bi) + .map(HsBookingItem::getProject) + .filter(bp -> bp.getCaption().equals(projectCaption)) + .isPresent(); + } + } + + @Nested + @Order(4) + class PatchBookingItem { + + @Test + void projectAgent_canPatchAllUpdatablePropertiesOfBookingItem() { + + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, + resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT") + .contentType(ContentType.JSON) + .body(""" + { + "validFrom": "2020-06-05", + "validTo": "2022-12-31", + "resources": { + "Traffic": 500, + "HDD": null, + "SSD": 100 + } + } + """) + .port(port) + .when() + .patch("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "caption": "some test-booking", + "validFrom": "2022-11-01", + "validTo": "2022-12-31", + "resources": { + "Traffic": 500, + "SSD": 100 + } + } + """)); // @formatter:on + + // finally, the bookingItem is actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(realBookingItemRepo.findByUuid(givenBookingItem.getUuid())).isPresent().get() + .matches(mandate -> { + assertThat(mandate.getProject().getDebitor().toString()).isEqualTo("booking-debitor(D-1000111: fir)"); + assertThat(mandate.getValidFrom()).isEqualTo("2022-11-01"); + assertThat(mandate.getValidTo()).isEqualTo("2022-12-31"); + return true; + }); + } + } + + @Nested + @Order(5) + class DeleteBookingItem { + + @Test + void globalAdmin_canDeleteArbitraryBookingItem() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, + resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given bookingItem is gone + assertThat(realBookingItemRepo.findByUuid(givenBookingItem.getUuid())).isEmpty(); + } + + @Test + void normalUser_canNotDeleteUnrelatedBookingItem() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, + resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid()) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + + // then the given bookingItem is still there + assertThat(realBookingItemRepo.findByUuid(givenBookingItem.getUuid())).isNotEmpty(); + } + } + + @SafeVarargs + private HsBookingItem givenSomeNewBookingItem(final String projectCaption, + final HsBookingItemType hsBookingItemType, final Map.Entry... resources) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var givenProject = realProjectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); + final var newBookingItem = HsBookingItemRealEntity.builder() + .uuid(UUID.randomUUID()) + .project(givenProject) + .type(hsBookingItemType) + .caption("some test-booking") + .resources(Map.ofEntries(resources)) + .validity(Range.closedOpen( + LocalDate.parse("2022-11-01"), LocalDate.parse("2023-03-31"))) + .build(); + + return realBookingItemRepo.save(newBookingItem); + }).assertSuccessful().returnedValue(); + } + + private Map.Entry resource(final String key, final Object value) { + return entry(key, value); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java new file mode 100644 index 00000000..55893753 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java @@ -0,0 +1,171 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.SynchronizationType; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.hamcrest.Matchers.matchesRegex; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsBookingItemController.class) +@Import(Mapper.class) +@RunWith(SpringRunner.class) +class HsBookingItemControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @Mock + EntityManager em; + + @MockBean + EntityManagerFactory emf; + + @MockBean + HsBookingProjectRealRepository realProjectRepo; + + @MockBean + HsBookingItemRbacRepository rbacBookingItemRepo; + + @BeforeEach + void init() { + when(emf.createEntityManager()).thenReturn(em); + when(emf.createEntityManager(any(Map.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em); + } + + @Nested + class AddBookingItem { + + @Test + void globalAdmin_canAddValidBookingItem() throws Exception { + + final var givenProjectUuid = UUID.randomUUID(); + + // given + when(em.find(HsBookingProjectRealEntity.class, givenProjectUuid)).thenAnswer(invocation -> + HsBookingProjectRealEntity.builder() + .uuid(invocation.getArgument(1)) + .build() + ); + when(rbacBookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/booking/items") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "projectUuid": "{projectUuid}", + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validTo": "{validTo}", + "garbage": "should not be accepted", + "resources": { "CPU": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{projectUuid}", givenProjectUuid.toString()) + .replace("{validTo}", LocalDate.now().plusMonths(1).toString()) + ) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{today}", + "validTo": "{todayPlus1Month}", + "resources": { "CPU": 12, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + )) + .andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*"))); + } + + @Test + void globalAdmin_canNotAddInvalidBookingItem() throws Exception { + + final var givenProjectUuid = UUID.randomUUID(); + + // given + when(em.find(HsBookingProjectRealEntity.class, givenProjectUuid)).thenAnswer(invocation -> + HsBookingProjectRealEntity.builder() + .uuid(invocation.getArgument(1)) + .build() + ); + when(rbacBookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/booking/items") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "projectUuid": "{projectUuid}", + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{validFrom}", + "resources": { "CPU": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{projectUuid}", givenProjectUuid.toString()) + .replace("{validFrom}", LocalDate.now().plusMonths(1).toString()) + ) + .accept(MediaType.APPLICATION_JSON)) + + // then + // TODO.test: MockMvc does not seem to validate additionalProperties=false + // .andExpect(status().is4xxClientError()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{today}", + "validTo": null, + "resources": { "CPU": 12, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + )) + .andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*"))); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java new file mode 100644 index 00000000..2113166c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java @@ -0,0 +1,112 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import io.hypersistence.utils.hibernate.type.range.Range; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsBookingItemEntityPatcherUnitTest extends PatchUnitTestBase< + HsBookingItemPatchResource, + HsBookingItem + > { + + private static final UUID INITIAL_BOOKING_ITEM_UUID = UUID.randomUUID(); + private static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-04-15"); + private static final LocalDate PATCHED_VALID_TO = LocalDate.parse("2022-12-31"); + + private static final Map INITIAL_RESOURCES = patchMap( + entry("CPU", 1), + entry("HDD", 1024), + entry("MEM", 64) + ); + private static final Map PATCH_RESOURCES = patchMap( + entry("CPU", 2), + entry("HDD", null), + entry("SSD", 256) + ); + private static final Map PATCHED_RESOURCES = patchMap( + entry("CPU", 2), + entry("SSD", 256), + entry("MEM", 64) + ); + + private static final String INITIAL_CAPTION = "initial caption"; + private static final String PATCHED_CAPTION = "patched caption"; + + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsOfficeDebitorEntity.class), any())).thenAnswer(invocation -> + HsOfficeDebitorEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsBookingItem.class), any())).thenAnswer(invocation -> + HsBookingItemRbacEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + @Override + protected HsBookingItem newInitialEntity() { + final var entity = new HsBookingItemRbacEntity(); + entity.setUuid(INITIAL_BOOKING_ITEM_UUID); + entity.setProject(PROJECT_TEST_ENTITY); + entity.getResources().putAll(KeyValueMap.from(INITIAL_RESOURCES)); + entity.setCaption(INITIAL_CAPTION); + entity.setValidity(Range.closedInfinite(GIVEN_VALID_FROM)); + return entity; + } + + @Override + protected HsBookingItemPatchResource newPatchResource() { + return new HsBookingItemPatchResource(); + } + + @Override + protected HsBookingItemEntityPatcher createPatcher(final HsBookingItem bookingItem) { + return new HsBookingItemEntityPatcher(bookingItem); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "caption", + HsBookingItemPatchResource::setCaption, + PATCHED_CAPTION, + HsBookingItem::setCaption), + new SimpleProperty<>( + "resources", + HsBookingItemPatchResource::setResources, + PATCH_RESOURCES, + HsBookingItem::putResources, + PATCHED_RESOURCES) + .notNullable(), + new JsonNullableProperty<>( + "validto", + HsBookingItemPatchResource::setValidTo, + PATCHED_VALID_TO, + HsBookingItem::setValidTo) + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java new file mode 100644 index 00000000..ef4ea740 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java @@ -0,0 +1,81 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.time.LocalDate; +import java.time.Month; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; +import static org.assertj.core.api.Assertions.assertThat; + +class HsBookingItemEntityUnitTest { + public static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-01-01"); + public static final LocalDate GIVEN_VALID_TO = LocalDate.parse("2030-12-31"); + + private MockedStatic localDateMockedStatic = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS); + + final HsBookingItem givenBookingItem = HsBookingItemRbacEntity.builder() + .project(PROJECT_TEST_ENTITY) + .type(HsBookingItemType.CLOUD_SERVER) + .caption("some caption") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("SSD-storage", 512), + entry("HDD-storage", 2048))) + .validity(toPostgresDateRange(GIVEN_VALID_FROM, GIVEN_VALID_TO)) + .build(); + + @AfterEach + void tearDown() { + localDateMockedStatic.close(); + } + + @Test + void validityStartsToday() { + // given + final var fakedToday = LocalDate.of(2024, Month.MAY, 1); + localDateMockedStatic.when(LocalDate::now).thenReturn(fakedToday); + + // when + final var newBookingItem = HsBookingItemRbacEntity.builder().build(); + + // then + assertThat(newBookingItem.getValidity().toString()).isEqualTo("Range{lower=2024-05-01, upper=null, mask=82, clazz=class java.time.LocalDate}"); + } + + @Test + void toStringContainsAllPropertiesAndResourcesSortedByKey() { + final var result = givenBookingItem.toString(); + + assertThat(result).isEqualToIgnoringWhitespace("HsBookingItem(CLOUD_SERVER, some caption, D-1234500:test project, [2020-01-01,2031-01-01), { \"CPU\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })"); + } + + @Test + void toShortStringContainsOnlyMemberNumberAndCaption() { + final var result = givenBookingItem.toShortString(); + + assertThat(result).isEqualTo("D-1234500:test project:some caption"); + } + + @Test + void settingValidFromKeepsValidTo() { + givenBookingItem.setValidFrom(LocalDate.parse("2023-12-31")); + assertThat(givenBookingItem.getValidFrom()).isEqualTo(LocalDate.parse("2023-12-31")); + assertThat(givenBookingItem.getValidTo()).isEqualTo(GIVEN_VALID_TO); + + } + + @Test + void settingValidToKeepsValidFrom() { + givenBookingItem.setValidTo(LocalDate.parse("2024-12-31")); + assertThat(givenBookingItem.getValidFrom()).isEqualTo(GIVEN_VALID_FROM); + assertThat(givenBookingItem.getValidTo()).isEqualTo(LocalDate.parse("2024-12-31")); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java new file mode 100644 index 00000000..ca931e44 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java @@ -0,0 +1,395 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import io.hypersistence.utils.hibernate.type.range.Range; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +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.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.JpaSystemException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ Context.class, JpaAttempt.class }) +class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + HsBookingItemRbacRepository rbacBookingItemRepo; + + @Autowired + HsBookingProjectRealRepository realProjectRepo; + + @Autowired + HsOfficeDebitorRepository debitorRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @MockBean + HttpServletRequest request; + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select currentTask, targetTable, targetOp, targetdelta->>'caption' + from tx_journal_v + where targettable = 'hs_booking_item'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating booking-item test-data, hs_booking_item, INSERT, prod CloudServer]", + "[creating booking-item test-data, hs_booking_item, INSERT, separate ManagedServer]", + "[creating booking-item test-data, hs_booking_item, INSERT, separate ManagedWebspace]", + "[creating booking-item test-data, hs_booking_item, INSERT, some ManagedServer]", + "[creating booking-item test-data, hs_booking_item, INSERT, some ManagedWebspace]", + "[creating booking-item test-data, hs_booking_item, INSERT, some PrivateCloud]", + "[creating booking-item test-data, hs_booking_item, INSERT, test CloudServer]"); + } + + @Test + public void historizationIsAvailable() { + // given + final String nativeQuerySql = """ + select count(*) + from hs_booking_item_hv ha; + """; + + // when + historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); + final var query = em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + + // then + assertThat(countBefore).as("hs_booking_item should not contain rows for a timestamp in the past").isEqualTo(0); + + // and when + historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); + em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + + // then + assertThat(countAfter).as("hs_booking_item should contain rows for a timestamp in the future").isGreaterThan(1); + } + + @Nested + class CreateBookingItem { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewBookingItem() { + // given + context("superuser-alex@hostsharing.net"); + final var count = rbacBookingItemRepo.count(); + final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + final var givenProject = realProjectRepo.findAllByDebitorUuid(givenDebitor.getUuid()).get(0); + + // when + final var result = attempt(em, () -> { + final var newBookingItem = HsBookingItemRbacEntity.builder() + .project(givenProject) + .type(HsBookingItemType.CLOUD_SERVER) + .caption("some new booking item") + .validity(Range.closedOpen( + LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) + .build(); + return toCleanup(rbacBookingItemRepo.save(newBookingItem)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsBookingItem::getUuid).isNotNull(); + assertThatBookingItemIsPersisted(result.returnedValue()); + assertThat(rbacBookingItemRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); + + // when + attempt(em, () -> { + final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + final var givenProject = realProjectRepo.findAllByDebitorUuid(givenDebitor.getUuid()).get(0); + final var newBookingItem = HsBookingItemRbacEntity.builder() + .project(givenProject) + .type(MANAGED_WEBSPACE) + .caption("some new booking item") + .validity(Range.closedOpen( + LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) + .build(); + return toCleanup(rbacBookingItemRepo.save(newBookingItem)); + }); + + // then + final var all = rawRoleRepo.findAll(); + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_booking_item#somenewbookingitem:ADMIN", + "hs_booking_item#somenewbookingitem:AGENT", + "hs_booking_item#somenewbookingitem:OWNER", + "hs_booking_item#somenewbookingitem:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) + .containsExactlyInAnyOrder(fromFormatted( + initialGrantNames, + + // global-admin + "{ grant perm:hs_booking_item#somenewbookingitem:INSERT>hs_booking_item to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:DELETE to role:global#global:ADMIN by system and assume }", + + // owner + "{ grant role:hs_booking_item#somenewbookingitem:OWNER to role:hs_booking_project#D-1000111-D-1000111defaultproject:AGENT by system and assume }", + + // admin + "{ grant perm:hs_booking_item#somenewbookingitem:UPDATE to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", + "{ grant role:hs_booking_item#somenewbookingitem:ADMIN to role:hs_booking_item#somenewbookingitem:OWNER by system and assume }", + + // agent + "{ grant role:hs_booking_item#somenewbookingitem:AGENT to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", + + // tenant + "{ grant role:hs_booking_item#somenewbookingitem:TENANT to role:hs_booking_item#somenewbookingitem:AGENT by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:SELECT to role:hs_booking_item#somenewbookingitem:TENANT by system and assume }", + "{ grant role:hs_booking_project#D-1000111-D-1000111defaultproject:TENANT to role:hs_booking_item#somenewbookingitem:TENANT by system and assume }", + null)); + } + + private void assertThatBookingItemIsPersisted(final HsBookingItem saved) { + final var found = rbacBookingItemRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().map(HsBookingItem::toString).get().isEqualTo(saved.toString()); + } + } + + @Nested + class FindByDebitorUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllBookingItemsOfArbitraryDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var projectUuid = debitorRepo.findDebitorByDebitorNumber(1000212).stream() + .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findAny().orElseThrow().getUuid(); + + // when + final var result = rbacBookingItemRepo.findAllByProjectUuid(projectUuid); + + // then + allTheseBookingItemsAreReturned( + result, + "HsBookingItem(MANAGED_SERVER, separate ManagedServer, D-1000212:D-1000212 default project, [2022-10-01,), { CPU: 2, RAM: 8, SSD: 500, Traffic: 500 })", + "HsBookingItem(MANAGED_WEBSPACE, separate ManagedWebspace, D-1000212:D-1000212 default project, [2022-10-01,), { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 })", + "HsBookingItem(PRIVATE_CLOUD, some PrivateCloud, D-1000212:D-1000212 default project, [2024-04-01,), { CPU: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 })"); + } + + @Test + public void normalUser_canViewOnlyRelatedBookingItems() { + // given: + context("person-FirbySusan@example.com"); + final var debitor = debitorRepo.findDebitorByDebitorNumber(1000111); + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-D-1000111defaultproject:OWNER"); + final var projectUuid = debitor.stream() + .map(d -> realProjectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findAny().orElseThrow().getUuid(); + + // when: + final var result = rbacBookingItemRepo.findAllByProjectUuid(projectUuid); + + // then: + exactlyTheseBookingItemsAreReturned( + result, + "HsBookingItem(MANAGED_SERVER, separate ManagedServer, D-1000111:D-1000111 default project, [2022-10-01,), { CPU : 2, RAM : 8, SSD : 500, Traffic : 500 })", + "HsBookingItem(MANAGED_WEBSPACE, separate ManagedWebspace, D-1000111:D-1000111 default project, [2022-10-01,), { Daemons : 0, Multi : 1, SSD : 100, Traffic : 50 })", + "HsBookingItem(PRIVATE_CLOUD, some PrivateCloud, D-1000111:D-1000111 default project, [2024-04-01,), { CPU : 10, HDD : 10000, RAM : 32, SSD : 4000, Traffic : 2000 })"); + } + } + + @Nested + class UpdateBookingItem { + + @Test + public void hostsharingAdmin_canUpdateArbitraryBookingItem() { + // given + final var givenBookingItemUuid = givenSomeTemporaryBookingItem("D-1000111 default project").getUuid(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var foundBookingItem = em.find(HsBookingItemRbacEntity.class, givenBookingItemUuid); + foundBookingItem.getResources().put("CPU", 2); + foundBookingItem.getResources().remove("SSD-storage"); + foundBookingItem.getResources().put("HSD-storage", 2048); + foundBookingItem.setValidity(Range.closedOpen( + LocalDate.parse("2019-05-17"), LocalDate.parse("2023-01-01"))); + return toCleanup(rbacBookingItemRepo.save(foundBookingItem)); + }); + + // then + result.assertSuccessful(); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + assertThatBookingItemActuallyInDatabase(result.returnedValue()); + }).assertSuccessful(); + } + + private void assertThatBookingItemActuallyInDatabase(final HsBookingItem saved) { + final var found = rbacBookingItemRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved) + .extracting(HsBookingItem::getResources) + .extracting(Object::toString) + .isEqualTo(saved.getResources().toString()); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyBookingItem() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + rbacBookingItemRepo.deleteByUuid(givenBookingItem.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return rbacBookingItemRepo.findByUuid(givenBookingItem.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void nonGlobalAdmin_canNotDeleteTheirRelatedBookingItem() { + // given + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + assertThat(rbacBookingItemRepo.findByUuid(givenBookingItem.getUuid())).isPresent(); + + rbacBookingItemRepo.deleteByUuid(givenBookingItem.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " is not allowed to delete hs_booking_item"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return rbacBookingItemRepo.findByUuid(givenBookingItem.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingABookingItemAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return rbacBookingItemRepo.deleteByUuid(givenBookingItem.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + private HsBookingItem givenSomeTemporaryBookingItem(final String projectCaption) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenProject = realProjectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); + final var newBookingItem = HsBookingItemRbacEntity.builder() + .project(givenProject) + .type(MANAGED_SERVER) + .caption("some temp booking item") + .validity(Range.closedOpen( + LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) + .resources(Map.ofEntries( + entry("CPU", 1), + entry("SSD-storage", 256))) + .build(); + + return toCleanup(rbacBookingItemRepo.save(newBookingItem)); + }).assertSuccessful().returnedValue(); + } + + void exactlyTheseBookingItemsAreReturned( + final List actualResult, + final String... bookingItemNames) { + assertThat(actualResult) + .extracting(HsBookingItem::toString) + .extracting(string-> string.replaceAll("\\s+", " ")) + .extracting(string-> string.replaceAll("\"", "")) + .containsExactlyInAnyOrder(bookingItemNames); + } + + void allTheseBookingItemsAreReturned(final List actualResult, final String... bookingItemNames) { + assertThat(actualResult) + .extracting(HsBookingItem::toString) + .extracting(string -> string.replaceAll("\\s+", " ")) + .extracting(string -> string.replaceAll("\"", "")) + .extracting(string -> string.replaceAll(" : ", ": ")) + .contains(bookingItemNames); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java new file mode 100644 index 00000000..3ea16dba --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java @@ -0,0 +1,52 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import io.hypersistence.utils.hibernate.type.range.Range; +import lombok.experimental.UtilityClass; + +import java.time.LocalDate; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; + +@UtilityClass +public class TestHsBookingItem { + + public static final HsBookingItemRealEntity CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY = HsBookingItemRealEntity.builder() + .project(PROJECT_TEST_ENTITY) + .type(HsBookingItemType.CLOUD_SERVER) + .caption("test cloud server booking item") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("RAM", 4), + entry("SSD", 50), + entry("Traffic", 250) + )) + .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) + .build(); + + public static final HsBookingItemRealEntity MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY = HsBookingItemRealEntity.builder() + .project(PROJECT_TEST_ENTITY) + .type(HsBookingItemType.MANAGED_SERVER) + .caption("test project booking item") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("RAM", 4), + entry("SSD", 50), + entry("Traffic", 250) + )) + .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) + .build(); + + public static final HsBookingItemRealEntity MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY = HsBookingItemRealEntity.builder() + .parentItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .type(HsBookingItemType.MANAGED_WEBSPACE) + .caption("test managed webspace item") + .resources(Map.ofEntries( + entry("SSD", 50), + entry("Traffic", 250) + )) + .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) + .build(); + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java new file mode 100644 index 00000000..7195126e --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java @@ -0,0 +1,59 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; +import jakarta.validation.ValidationException; + +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HsBookingItemEntityValidatorUnitTest { + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("test project") + .build(); + + private EntityManager em; + + @Test + void rejectsInvalidEntity() { + // given + final var cloudServerBookingItemEntity = HsBookingItemRealEntity.builder() + .type(CLOUD_SERVER) + .project(project) + .caption("Test-Server") + .build(); + + // when + final var result = catchThrowable( ()-> HsBookingItemEntityValidatorRegistry.validated(em, cloudServerBookingItemEntity)); + + // then + assertThat(result).isInstanceOf(ValidationException.class) + .hasMessageContaining( + "'D-12345:test project:Test-Server.resources.CPU' is required but missing", + "'D-12345:test project:Test-Server.resources.RAM' is required but missing", + "'D-12345:test project:Test-Server.resources.SSD' is required but missing", + "'D-12345:test project:Test-Server.resources.Traffic' is required but missing"); + } + + @Test + void listsTypes() { + // when + final var result = HsBookingItemEntityValidatorRegistry.types(); + + // then + assertThat(result).containsExactlyInAnyOrder(PRIVATE_CLOUD, CLOUD_SERVER, MANAGED_SERVER, MANAGED_WEBSPACE, DOMAIN_SETUP); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java new file mode 100644 index 00000000..ae7b9508 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java @@ -0,0 +1,122 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; +import java.util.Map; + +import static java.util.List.of; +import static java.util.Map.entry; +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; +import static org.assertj.core.api.Assertions.assertThat; + +class HsCloudServerBookingItemValidatorUnitTest { + + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + private EntityManager em; + + @Test + void validatesProperties() { + // given + final var cloudServerBookingItemEntity = HsBookingItemRealEntity.builder() + .type(CLOUD_SERVER) + .project(project) + .caption("Test-Server") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("RAM", 25), + entry("SSD", 25), + entry("Traffic", 250), + entry("SLA-EMail", true) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, cloudServerBookingItemEntity); + + // then + assertThat(result).containsExactly("'D-12345:Test-Project:Test-Server.resources.SLA-EMail' is not expected but is set to 'true'"); + } + + @Test + void containsAllValidations() { + // when + final var validator = HsBookingItemEntityValidatorRegistry.forType(CLOUD_SERVER); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=boolean, propertyName=active, defaultValue=true}", + "{type=integer, propertyName=CPU, min=1, max=32, required=true}", + "{type=integer, propertyName=RAM, unit=GB, min=1, max=8192, required=true}", + "{type=integer, propertyName=SSD, unit=GB, min=25, max=1000, step=25, requiresAtLeastOneOf=[SDD, HDD]}", + "{type=integer, propertyName=HDD, unit=GB, min=250, max=4000, step=250, requiresAtLeastOneOf=[SSD, HDD]}", + "{type=integer, propertyName=Traffic, unit=GB, min=250, max=10000, step=250, requiresAtMaxOneOf=[Bandwidth, Traffic]}", + "{type=integer, propertyName=Bandwidth, unit=GB, min=250, max=10000, step=250, requiresAtMaxOneOf=[Bandwidth, Traffic]}", + "{type=enumeration, propertyName=SLA-Infrastructure, values=[BASIC, EXT8H, EXT4H, EXT2H]}"); + } + + @Test + void validatesExceedingPropertyTotals() { + // given + final var subCloudServerBookingItemEntity = HsBookingItemRealEntity.builder() + .type(CLOUD_SERVER) + .caption("Test Cloud-Server") + .resources(ofEntries( + entry("CPU", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500) + )) + .build(); + final HsBookingItemRealEntity subManagedServerBookingItemEntity = HsBookingItemRealEntity.builder() + .type(MANAGED_SERVER) + .caption("Test Managed-Server") + .resources(ofEntries( + entry("CPU", 3), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 3000) + )) + .build(); + final var privateCloudBookingItemEntity = HsBookingItemRealEntity.builder() + .type(PRIVATE_CLOUD) + .project(project) + .caption("Test Cloud") + .resources(ofEntries( + entry("CPU", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000) + )) + .subBookingItems(of( + subManagedServerBookingItemEntity, + subCloudServerBookingItemEntity + )) + .build(); + subManagedServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + subCloudServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, subCloudServerBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:Test Cloud.resources.CPU' maximum total is 4, but actual total CPU is 5", + "'D-12345:Test-Project:Test Cloud.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB", + "'D-12345:Test-Project:Test Cloud.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB", + "'D-12345:Test-Project:Test Cloud.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB" + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java new file mode 100644 index 00000000..60356401 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java @@ -0,0 +1,175 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import jakarta.persistence.EntityManager; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; +import static org.apache.commons.lang3.StringUtils.right; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainSetupBookingItemValidatorUnitTest { + + public static final String TOO_LONG_DOMAIN_NAME = "asdfghijklmnopqrstuvwxyz0123456789.".repeat(8) + "example.org"; + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + private EntityManager em; + + @Test + void acceptsRegisterableDomainWithGeneratedVerificationCode() { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", "example.org") + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void acceptsRegisterableDomainWithExplicitVerificationCode() { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", "example.org"), + entry("verificationCode", "1234-5678-9100") + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void acceptsMaximumDomainNameLength() { + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253)) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsTooLongTotalName() { + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254)) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.domainName' length is expected to be at max 253 but length of 'dfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.example.org' is 254"); + } + + @ParameterizedTest + @ValueSource(strings = { + "de", "com", "net", "org", "actually-any-top-level-domain", + "co.uk", "org.uk", "gov.uk", "ac.uk", "sch.uk", + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", + "com.br", "net.br", "org.br", "gov.br", "edu.br", "mil.br", "art.br", + "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in", + "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx", + "gov.it", "edu.it", + "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz", + "co.kr", "ne.kr", "or.kr", "go.kr", "re.kr", "pe.kr" + }) + void rejectRegistrarLevelDomain(final String secondLevelRegistrarDomain) { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", secondLevelRegistrarDomain) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).contains( + "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" + + secondLevelRegistrarDomain + + "' is a forbidden registrar-level domain name"); + } + + @ParameterizedTest + @ValueSource(strings = { + "hostsharing.net", "hostsharing.org", "hostsharing.com", "hostsharing.coop", "hostsharing.de" + }) + void rejectHostsharingDomain(final String secondLevelRegistrarDomain) { + // given + final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder() + .type(DOMAIN_SETUP) + .project(project) + .caption("Test-Domain") + .resources(Map.ofEntries( + entry("domainName", secondLevelRegistrarDomain) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity); + + // then + assertThat(result).containsExactly( + "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" + + secondLevelRegistrarDomain + + "' is a forbidden Hostsharing domain name"); + } + + @Test + void containsAllValidations() { + // when + final var validator = HsBookingItemEntityValidatorRegistry.forType(DOMAIN_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(? concat(final List... hostingAssets) { + return stream(hostingAssets) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + private List generate(final int count, final HsHostingAssetType hostingAssetType, + final String identifierPattern) { + return IntStream.range(0, count) + .mapToObj(number -> (HsHostingAssetRealEntity) HsHostingAssetRealEntity.builder() + .type(hostingAssetType) + .identifier(identifierPattern.formatted((number/'a')+'a', (number%'a')+'a')) + .build()) + .toList(); + } + + private List generateDbUsersWithDatabases( + final int userCount, + final HsHostingAssetType directAssetType, + final String directAssetIdentifierFormat, + final int dbCount, + final HsHostingAssetType subAssetType) { + final List list = IntStream.range(0, userCount) + .mapToObj(n -> HsHostingAssetRealEntity.builder() + .type(directAssetType) + .identifier(directAssetIdentifierFormat.formatted((n / 'a') + 'a', (n % 'a') + 'a')) + .subHostingAssets( + generate(dbCount, subAssetType, "%c%c.example.com" .formatted((n / 'a') + 'a', (n % 'a') + 'a')) + ) + .build()) + .toList(); + return list; + } + + private List generateDomainEmailSetupsWithEMailAddresses( + final int domainCount, + final HsHostingAssetType directAssetType, + final String directAssetIdentifierFormat, + final int emailAddressCount, + final HsHostingAssetType subAssetType) { + return IntStream.range(0, domainCount) + .mapToObj(n -> HsHostingAssetRealEntity.builder() + .type(directAssetType) + .identifier(directAssetIdentifierFormat.formatted((n/'a')+'a', (n%'a')+'a')) + .subHostingAssets( + generate(emailAddressCount, subAssetType, "xyz00_%c%c%%c%%c".formatted((n/'a')+'a', (n%'a')+'a')) + ) + .build()) + .toList(); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java new file mode 100644 index 00000000..526c7b92 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java @@ -0,0 +1,69 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static org.assertj.core.api.Assertions.assertThat; + +class HsManagedWebspaceBookingItemValidatorUnitTest { + + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + private EntityManager em; + + @Test + void validatesProperties() { + // given + final var mangedServerBookingItemEntity = HsBookingItemRealEntity.builder() + .type(MANAGED_WEBSPACE) + .project(project) + .caption("Test Managed-Webspace") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("RAM", 25), + entry("Traffic", 250), + entry("SLA-EMail", true) + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, mangedServerBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:Test Managed-Webspace.resources.CPU' is not expected but is set to '2'", + "'D-12345:Test-Project:Test Managed-Webspace.resources.RAM' is not expected but is set to '25'", + "'D-12345:Test-Project:Test Managed-Webspace.resources.SSD' is required but missing", + "'D-12345:Test-Project:Test Managed-Webspace.resources.SLA-EMail' is not expected but is set to 'true'" + ); + } + + @Test + void containsAllValidations() { + // when + final var validator = HsBookingItemEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=integer, propertyName=SSD, unit=GB, min=1, max=2000, step=1, required=true}", + "{type=integer, propertyName=HDD, unit=GB, min=0, max=10000, step=10}", + "{type=integer, propertyName=Traffic, unit=GB, min=10, max=64000, step=10, requiresAtMaxOneOf=[Bandwidth, Traffic]}", + "{type=integer, propertyName=Bandwidth, unit=GB, min=10, max=1000, step=10, requiresAtMaxOneOf=[Bandwidth, Traffic]}", + "{type=integer, propertyName=Multi, min=1, max=100, step=1, defaultValue=1}", + "{type=integer, propertyName=Daemons, min=0, max=16, defaultValue=0}", + "{type=boolean, propertyName=Online Office Server}", + "{type=enumeration, propertyName=SLA-Platform, values=[BASIC, EXT24H], defaultValue=BASIC}"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java new file mode 100644 index 00000000..67e35806 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java @@ -0,0 +1,144 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; + +import static java.util.List.of; +import static java.util.Map.entry; +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; +import static org.assertj.core.api.Assertions.assertThat; + +class HsPrivateCloudBookingItemValidatorUnitTest { + + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + private EntityManager em; + + @Test + void validatesPropertyTotals() { + // given + final var privateCloudBookingItemEntity = HsBookingItemRealEntity.builder() + .type(PRIVATE_CLOUD) + .project(PROJECT_TEST_ENTITY) + .caption("myPC") + .resources(ofEntries( + entry("CPU", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000), + entry("SLA-Platform EXT4H", 2), + entry("SLA-EMail", 2) + )) + .subBookingItems(of( + HsBookingItemRealEntity.builder() + .type(MANAGED_SERVER) + .caption("myMS-1") + .resources(ofEntries( + entry("CPU", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) + .build(), + HsBookingItemRealEntity.builder() + .type(CLOUD_SERVER) + .caption("myMS-2") + .resources(ofEntries( + entry("CPU", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) + .build() + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, privateCloudBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesExceedingPropertyTotals() { + // given + final var privateCloudBookingItemEntity = HsBookingItemRealEntity.builder() + .project(project) + .type(PRIVATE_CLOUD) + .caption("myPC") + .resources(ofEntries( + entry("CPU", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000), + entry("SLA-Platform EXT2H", 1), + entry("SLA-EMail", 1) + )) + .subBookingItems(of( + HsBookingItemRealEntity.builder() + .type(MANAGED_SERVER) + .caption("myMS-1") + .resources(ofEntries( + entry("CPU", 3), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 3000), + entry("SLA-Platform", "EXT2H"), + entry("SLA-EMail", true) + )) + .build(), + HsBookingItemRealEntity.builder() + .type(CLOUD_SERVER) + .caption("myMS-2") + .resources(ofEntries( + entry("CPU", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT2H"), + entry("SLA-EMail", true), + entry("SLA-Maria", true), + entry("SLA-PgSQL", true), + entry("SLA-Office", true), + entry("SLA-Web", true) + )) + .build() + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, privateCloudBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:myPC.resources.CPU' maximum total is 4, but actual total CPU is 5", + "'D-12345:Test-Project:myPC.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB", + "'D-12345:Test-Project:myPC.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB", + "'D-12345:Test-Project:myPC.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB", + "'D-12345:Test-Project:myPC.resources.SLA-Platform EXT2H maximum total is 1, but actual total for SLA-Platform=EXT2H is 2", + "'D-12345:Test-Project:myPC.resources.SLA-EMail' maximum total is 1, but actual total SLA-EMail is 2", + "'D-12345:Test-Project:myPC.resources.SLA-Maria' maximum total is 0, but actual total SLA-Maria is 1", + "'D-12345:Test-Project:myPC.resources.SLA-PgSQL' maximum total is 0, but actual total SLA-PgSQL is 1", + "'D-12345:Test-Project:myPC.resources.SLA-Office' maximum total is 0, but actual total SLA-Office is 1", + "'D-12345:Test-Project:myPC.resources.SLA-Web' maximum total is 0, but actual total SLA-Web is 1" + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java new file mode 100644 index 00000000..c4bc8e2e --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java @@ -0,0 +1,278 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.matchesRegex; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +class HsBookingProjectControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + @LocalServerPort + private Integer port; + + @Autowired + HsBookingProjectRealRepository realProjectRepo; + + @Autowired + HsBookingDebitorRepository debitorRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @Nested + class ListBookingProjects { + + @Test + void globalAdmin_canViewAllBookingProjectsOfArbitraryDebitor() { + + // given + context("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).stream() + .findFirst() + .orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects?debitorUuid=" + givenDebitor.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "caption": "D-1000111 default project" + } + ] + """)); + // @formatter:on + } + } + + @Nested + class AddBookingProject { + + @Test + void globalAdmin_canAddBookingProject() { + + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).stream() + .findFirst() + .orElseThrow(); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "debitorUuid": "%s", + "caption": "some new project" + } + """.formatted(givenDebitor.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/booking/projects") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "caption": "some new project" + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/projects/[^/]*")) + .extract().header("Location"); // @formatter:on + + // finally, the new bookingProject can be accessed under the generated UUID + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newUserUuid).isNotNull(); + } + } + + @Nested + class GetBookingProject { + + @Test + void globalAdmin_canGetArbitraryBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = realProjectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "caption": "D-1000111 default project" + } + """)); // @formatter:on + } + + @Test + void normalUser_canNotGetUnrelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = realProjectRepo.findByCaption("D-1000212 default project").stream() + .map(HsBookingProject::getUuid) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + void projectAgentUser_canGetRelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = realProjectRepo.findByCaption("D-1000313 default project").stream() + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "person-TuckerJack@example.com") + .header("assumed-roles", "hs_booking_project#D-1000313-D-1000313defaultproject:AGENT") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "caption": "D-1000313 default project" + } + """)); // @formatter:on + } + } + + @Nested + class PatchBookingProject { + + @Test + void globalAdmin_canPatchAllUpdatablePropertiesOfBookingProject() { + + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "caption": "some project" + } + """) + .port(port) + .when() + .patch("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "caption": "some project" + } + """)); // @formatter:on + + // finally, the bookingProject is actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(realProjectRepo.findByUuid(givenBookingProject.getUuid())).isPresent().get() + .matches(mandate -> { + assertThat(mandate.getDebitor().toString()).isEqualTo("booking-debitor(D-1000111: fir)"); + return true; + }); + } + } + + @Nested + class DeleteBookingProject { + + @Test + void globalAdmin_canDeleteArbitraryBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given bookingProject is gone + assertThat(realProjectRepo.findByUuid(givenBookingProject.getUuid())).isEmpty(); + } + + @Test + void normalUser_canNotDeleteUnrelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + + // then the given bookingProject is still there + assertThat(realProjectRepo.findByUuid(givenBookingProject.getUuid())).isNotEmpty(); + } + } + + private HsBookingProjectRealEntity givenSomeBookingProject(final int debitorNumber, final String caption) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(debitorNumber).stream().findAny().orElseThrow(); + final var newBookingProject = HsBookingProjectRealEntity.builder() + .uuid(UUID.randomUUID()) + .debitor(givenDebitor) + .caption(caption) + .build(); + + return realProjectRepo.save(newBookingProject); + }).assertSuccessful().returnedValue(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java new file mode 100644 index 00000000..43f9c2b4 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java @@ -0,0 +1,72 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import java.util.UUID; +import java.util.stream.Stream; + +import static net.hostsharing.hsadminng.hs.booking.debitor.TestHsBookingDebitor.TEST_BOOKING_DEBITOR; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsBookingProjectEntityPatcherUnitTest extends PatchUnitTestBase< + HsBookingProjectPatchResource, + HsBookingProject + > { + + private static final UUID INITIAL_BOOKING_PROJECT_UUID = UUID.randomUUID(); + + private static final String INITIAL_CAPTION = "initial caption"; + private static final String PATCHED_CAPTION = "patched caption"; + + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsOfficeDebitorEntity.class), any())).thenAnswer(invocation -> + HsOfficeDebitorEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + @Override + protected HsBookingProject newInitialEntity() { + final var entity = new HsBookingProjectRbacEntity(); + entity.setUuid(INITIAL_BOOKING_PROJECT_UUID); + entity.setDebitor(TEST_BOOKING_DEBITOR); + entity.setCaption(INITIAL_CAPTION); + return entity; + } + + @Override + protected HsBookingProjectPatchResource newPatchResource() { + return new HsBookingProjectPatchResource(); + } + + @Override + protected HsBookingProjectEntityPatcher createPatcher(final HsBookingProject bookingProject) { + return new HsBookingProjectEntityPatcher(bookingProject); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "caption", + HsBookingProjectPatchResource::setCaption, + PATCHED_CAPTION, + HsBookingProject::setCaption) + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java new file mode 100644 index 00000000..c89651f4 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import org.junit.jupiter.api.Test; + +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; +import static org.assertj.core.api.Assertions.assertThat; + +class HsBookingProjectEntityUnitTest { + + @Test + void toStringContainsAllPropertiesAndResourcesSortedByKey() { + final var result = PROJECT_TEST_ENTITY.toString(); + + assertThat(result).isEqualTo("HsBookingProject(D-1234500, test project)"); + } + + @Test + void toShortStringContainsOnlyMemberNumberAndCaption() { + final var result = PROJECT_TEST_ENTITY.toShortString(); + + assertThat(result).isEqualTo("D-1234500:test project"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java new file mode 100644 index 00000000..b3a05ffa --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java @@ -0,0 +1,406 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.JpaSystemException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; + +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ Context.class, JpaAttempt.class }) +class HsBookingProjectRepositoryIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + HsBookingProjectRealRepository realProjectRepo; + + @Autowired + HsBookingProjectRbacRepository rbacProjectRepo; + + @Autowired + HsBookingDebitorRepository debitorRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @MockBean + HttpServletRequest request; + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select currentTask, targetTable, targetOp, targetdelta->>'caption' + from tx_journal_v + where targettable = 'hs_booking_project'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating booking-project test-data, hs_booking_project, INSERT, D-1000111 default project]", + "[creating booking-project test-data, hs_booking_project, INSERT, D-1000212 default project]", + "[creating booking-project test-data, hs_booking_project, INSERT, D-1000313 default project]"); + } + + @Test + public void historizationIsAvailable() { + // given + final String nativeQuerySql = """ + select count(*) + from hs_booking_project_hv ha; + """; + + // when + historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); + final var query = em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + + // then + assertThat(countBefore).as("hs_booking_project_hv should not contain rows for a timestamp in the past").isEqualTo(0); + + // and when + historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); + em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + + // then + assertThat(countAfter).as("hs_booking_project_hv should contain rows for a timestamp in the future").isGreaterThan(1); + } + + @Nested + class CreateBookingProject { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewBookingProject() { + // given + context("superuser-alex@hostsharing.net"); // TODO.test: remove once we have a realDebitorRepo + final var count = realProjectRepo.count(); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).get(0); + + // when + final var result = attempt(em, () -> { + final var newBookingProject = HsBookingProjectRbacEntity.builder() + .debitor(givenDebitor) + .caption("some new booking project") + .build(); + return toCleanup(rbacProjectRepo.save(newBookingProject)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsBookingProject::getUuid).isNotNull(); + assertThatBookingProjectIsPersisted(result.returnedValue()); + assertThat(realProjectRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() + .map(s -> s.replace("hs_office_", "")) + .toList(); + + // when + attempt(em, () -> { + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).get(0); + final var newBookingProject = HsBookingProjectRbacEntity.builder() + .debitor(givenDebitor) + .caption("some new booking project") + .build(); + return toCleanup(rbacProjectRepo.save(newBookingProject)); + }); + + // then + final var all = rawRoleRepo.findAll(); + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_booking_project#D-1000111-somenewbookingproject:ADMIN", + "hs_booking_project#D-1000111-somenewbookingproject:AGENT", + "hs_booking_project#D-1000111-somenewbookingproject:OWNER", + "hs_booking_project#D-1000111-somenewbookingproject:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) + .map(s -> s.replace("hs_office_", "")) + .containsExactlyInAnyOrder(fromFormatted( + initialGrantNames, + + // global-admin + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:DELETE to role:global#global:ADMIN by system and assume }", + + // owner + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN to role:hs_booking_project#D-1000111-somenewbookingproject:OWNER by system and assume }", + + // admin + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:AGENT to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:UPDATE to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:INSERT>hs_booking_item to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + + // agent + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:OWNER to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system }", + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:TENANT to role:hs_booking_project#D-1000111-somenewbookingproject:AGENT by system and assume }", + + // tenant + "{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:TENANT to role:hs_booking_project#D-1000111-somenewbookingproject:TENANT by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:SELECT to role:hs_booking_project#D-1000111-somenewbookingproject:TENANT by system and assume }", + + null)); + } + + private void assertThatBookingProjectIsPersisted(final HsBookingProject saved) { + final var found = rbacProjectRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().map(HsBookingProject::toString).get().isEqualTo(saved.toString()); + } + } + + @Nested + class FindByDebitorUuid { + + @ParameterizedTest + @EnumSource(TestCase.class) + public void globalAdmin_withoutAssumedRole_canViewAllBookingProjectsOfArbitraryDebitor(final TestCase testCase) { + // given + context("superuser-alex@hostsharing.net"); + final var debitorUuid = debitorRepo.findByDebitorNumber(1000212).stream() + .findAny().orElseThrow().getUuid(); + + // when + final var result = repoUnderTest(testCase).findAllByDebitorUuid(debitorUuid); + + // then + allTheseBookingProjectsAreReturned( + result, + "HsBookingProject(D-1000212, D-1000212 default project)"); + } + + @ParameterizedTest + @EnumSource(TestCase.class) + public void packetAgent_canViewOnlyRelatedBookingProjects(final TestCase testCase) { + + // given: + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var debitorUuid = debitorRepo.findByDebitorNumber(1000111).stream() + .findAny().orElseThrow().getUuid(); + + // when: + final var result = repoUnderTest(testCase).findAllByDebitorUuid(debitorUuid); + + // then: + assertResult(testCase, result, + "HsBookingProject(D-1000111, D-1000111 default project)"); + } + } + + @Nested + class UpdateBookingProject { + + @ParameterizedTest + @EnumSource(TestCase.class) + public void bookingProjectAdmin_canUpdateArbitraryBookingProject(final TestCase testCase) { + // given + final var givenBookingProjectUuid = givenSomeTemporaryBookingProject(1000111).getUuid(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-sometempproject:ADMIN"); + final var foundBookingProject = em.find(HsBookingProjectRbacEntity.class, givenBookingProjectUuid); + foundBookingProject.setCaption("updated caption"); + return toCleanup(repoUnderTest(testCase).save(foundBookingProject)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue().getCaption()).isEqualTo("updated caption"); + assertThatBookingProjectActuallyInDatabase(result.returnedValue()); + } + + private void assertThatBookingProjectActuallyInDatabase(final HsBookingProject saved) { + jpaAttempt.transacted(() -> { + final var found = realProjectRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved) + .extracting(Object::toString).isEqualTo(saved.toString()); + }).assertSuccessful(); + } + } + + @Nested + class DeleteByUuid { + + @ParameterizedTest + @EnumSource(TestCase.class) + public void globalAdmin_withoutAssumedRole_canDeleteAnyBookingProject(final TestCase testCase) { + // given + context("superuser-alex@hostsharing.net", null); + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + repoUnderTest(testCase).deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return rbacProjectRepo.findByUuid(givenBookingProject.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void nonGlobalAdmin_canNotDeleteTheirRelatedBookingProject() { + // given + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-sometempproject:AGENT"); + assertThat(rbacProjectRepo.findByUuid(givenBookingProject.getUuid())).isPresent(); + + repoUnderTest(TestCase.RBAC).deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " is not allowed to delete hs_booking_project"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return rbacProjectRepo.findByUuid(givenBookingProject.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @ParameterizedTest + @EnumSource(TestCase.class) + public void deletingABookingProjectAlsoDeletesRelatedRolesAndGrants(final TestCase testCase) { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return repoUnderTest(testCase).deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + private HsBookingProjectRealEntity givenSomeTemporaryBookingProject(final int debitorNumber) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(debitorNumber).get(0); + final var newBookingProject = HsBookingProjectRealEntity.builder() + .debitor(givenDebitor) + .caption("some temp project") + .build(); + + return toCleanup(realProjectRepo.save(newBookingProject)); + }).assertSuccessful().returnedValue(); + } + + void exactlyTheseBookingProjectsAreReturned( + final List actualResult, + final String... bookingProjectNames) { + assertThat(actualResult) + .extracting(HsBookingProject::toString) + .containsExactlyInAnyOrder(bookingProjectNames); + } + + void allTheseBookingProjectsAreReturned( + final List actualResult, + final String... bookingProjectNames) { + assertThat(actualResult) + .extracting(HsBookingProject::toString) + .contains(bookingProjectNames); + } + + private HsBookingProjectRepository repoUnderTest(final TestCase testCase) { + return testCase.repo(HsBookingProjectRepositoryIntegrationTest.this); + } + + private void assertResult( + final TestCase testCase, + final List actualResult, + final String... expectedProjects) { + testCase.assertResult(HsBookingProjectRepositoryIntegrationTest.this, actualResult, expectedProjects); + } + + enum TestCase { + REAL { + @Override + HsBookingProjectRepository repo(final HsBookingProjectRepositoryIntegrationTest test) { + return test.realProjectRepo; + } + + @Override + void assertResult( + final HsBookingProjectRepositoryIntegrationTest test, + final List result, + final String... expectedProjects) { + test.allTheseBookingProjectsAreReturned(result, expectedProjects); + } + }, + RBAC { + @Override + HsBookingProjectRepository repo(final HsBookingProjectRepositoryIntegrationTest test) { + return test.rbacProjectRepo; + } + + @Override + void assertResult( + final HsBookingProjectRepositoryIntegrationTest test, + final List result, + final String... expectedProjects) { + test.exactlyTheseBookingProjectsAreReturned(result, expectedProjects); + } + }; + + abstract HsBookingProjectRepository repo(final HsBookingProjectRepositoryIntegrationTest test); + + abstract void assertResult(final HsBookingProjectRepositoryIntegrationTest test, final List result, final String... expectedProjects); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java new file mode 100644 index 00000000..c75c4f83 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java @@ -0,0 +1,14 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.experimental.UtilityClass; + +import static net.hostsharing.hsadminng.hs.booking.debitor.TestHsBookingDebitor.TEST_BOOKING_DEBITOR; + +@UtilityClass +public class TestHsBookingProject { + + public static final HsBookingProjectRealEntity PROJECT_TEST_ENTITY = HsBookingProjectRealEntity.builder() + .debitor(TEST_BOOKING_DEBITOR) + .caption("test project") + .build(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/EntityManagerMock.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/EntityManagerMock.java new file mode 100644 index 00000000..5f6bdbcc --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/EntityManagerMock.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.jetbrains.annotations.NotNull; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +public class EntityManagerMock { + public static @NotNull EntityManager createEntityManagerMockWithAssetQueryFake(final HsHostingAssetRealEntity asset) { + final var em = mock(EntityManager.class); + final var assetQuery = mock(TypedQuery.class); + final var assetStream = mock(Stream.class); + + lenient().when(em.createQuery(any(), any(Class.class))).thenReturn(assetQuery); + lenient().when(assetQuery.getResultStream()).thenReturn(assetStream); + lenient().when(assetQuery.setParameter(anyString(), any())).thenReturn(assetQuery); + lenient().when(assetStream.findFirst()).thenReturn(Optional.ofNullable(asset)); + return em; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java new file mode 100644 index 00000000..81f3192e --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java @@ -0,0 +1,780 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ALIAS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.strictlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.matchesRegex; + +@Transactional +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) // fail early on fetching problems +class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + @LocalServerPort + private Integer port; + + @Autowired + HsHostingAssetRealRepository realAssetRepo; + + @Autowired + HsBookingItemRealRepository realBookingItemRepo; + + @Autowired + HsBookingProjectRealRepository realProjectRepo; + + @Autowired + HsOfficeContactRealRepository realContactRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @AfterEach + void cleanup() { + Dns.resetFakeResults(); + } + + @Nested + @Order(2) + class ListAssets { + + @Test + void globalAdmin_canViewAllAssetsOfArbitraryDebitor() { + + // given + context("superuser-alex@hostsharing.net"); + final var givenProject = realProjectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/hosting/assets?projectUuid=" + givenProject.getUuid() + "&type=MANAGED_WEBSPACE") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "type": "MANAGED_WEBSPACE", + "identifier": "fir01", + "caption": "some Webspace", + "config": {} + } + ] + """)); + // @formatter:on + } + + @Test + void webspaceAgent_canViewAllAssetsByType() { + + // given + context("superuser-alex@hostsharing.net"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_hosting_asset#fir01:AGENT") + .port(port) + .when() + . get("http://localhost/api/hs/hosting/assets?type=" + EMAIL_ALIAS) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "type": "EMAIL_ALIAS", + "identifier": "fir01-web", + "caption": "some E-Mail-Alias", + "alarmContact": null, + "config": { + "target": [ + "office@example.org", + "archive@example.com" + ] + } + } + ] + """)); + // @formatter:on + } + } + + @Nested + @Order(3) + class AddAsset { + + @Test + void globalAdmin_canAddBookedAsset() { + + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItem = newBookingItem("D-1000111 default project", + HsBookingItemType.MANAGED_WEBSPACE, "separate ManagedWebspace BI", + Map.ofEntries( + entry("SSD", 50), + entry("Traffic", 50) + ) + ); + final var givenParentAsset = givenParentAsset(MANAGED_SERVER, "vm1011"); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "bookingItemUuid": "%s", + "type": "MANAGED_WEBSPACE", + "identifier": "fir10", + "parentAssetUuid": "%s", + "caption": "some separate ManagedWebspace HA", + "config": {} + } + """.formatted(givenBookingItem.getUuid(), givenParentAsset.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "MANAGED_WEBSPACE", + "identifier": "fir10", + "caption": "some separate ManagedWebspace HA", + "config": { + "groupid": 1000000 + } + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) + .extract().header("Location"); // @formatter:on + + // the new asset can be accessed under the generated UUID + final var newWebspaceUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newWebspaceUuid).isNotNull(); + toCleanup(HsHostingAssetRbacEntity.class, newWebspaceUuid); + + // and a default user got created + final var webspaceUnixUser = em.createQuery("SELECT ha FROM HsHostingAssetRealEntity ha WHERE ha.parentAsset.uuid=:webspaceUUID") + .setParameter("webspaceUUID", newWebspaceUuid) + .getSingleResult(); + assertThat(webspaceUnixUser).isNotNull().extracting(Object::toString) + .isEqualTo(""" + HsHostingAsset(UNIX_USER, fir10, fir10 webspace user, MANAGED_WEBSPACE:fir10, { + "password" : null, + "userid" : 1000000 + }) + """.trim()); + } + + @Test + void parentAssetAgent_canAddSubAsset() { + + final var givenParentAsset = givenParentAsset(MANAGED_WEBSPACE, "fir01"); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_hosting_asset#vm1011:ADMIN") + .contentType(ContentType.JSON) + .body(""" + { + "parentAssetUuid": "%s", + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some new UnixUser in client's ManagedWebspace", + "config": {} + } + """.formatted(givenParentAsset.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some new UnixUser in client's ManagedWebspace", + "config": {} + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) + .extract().header("Location"); // @formatter:on + + // finally, the new asset can be accessed under the generated UUID + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newUserUuid).isNotNull(); + } + + @Test + void globalAdmin_canAddTopLevelAsset() { + + context.define("superuser-alex@hostsharing.net"); + Dns.fakeResultForDomain("example.com", new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null)); + final var givenProject = realProjectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow(); + final var bookingItem = givenSomeTemporaryBookingItem(() -> + HsBookingItemRealEntity.builder() + .project(givenProject) + .type(HsBookingItemType.DOMAIN_SETUP) + .caption("some temp domain setup booking item") + .resources(Map.ofEntries( + entry("domainName", "example.com"))) + .build() + ); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "bookingItemUuid": "%s", + "type": "DOMAIN_SETUP", + "identifier": "example.com", + "caption": "some unrelated domain-setup", + "config": {} + } + """.formatted(bookingItem.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "DOMAIN_SETUP", + "identifier": "example.com", + "caption": "some unrelated domain-setup", + "config": {} + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) + .extract().header("Location"); // @formatter:on + + // finally, the new asset can be accessed under the generated UUID + final var newWebspace = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newWebspace).isNotNull(); + toCleanup(HsHostingAssetRbacEntity.class, newWebspace); + } + + @Test + void propertyValidationsArePerformend_whenAddingAsset() { + + context.define("superuser-alex@hostsharing.net"); + final var givenBookingItem = givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "some PrivateCloud"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "bookingItemUuid": "%s", + "type": "MANAGED_SERVER", + "identifier": "vm1400", + "caption": "some new ManagedServer", + "config": { "monit_max_ssd_usage": 0, "monit_max_cpu_usage": 101, "extra": 42 } + } + """.formatted(givenBookingItem.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(400) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "statusPhrase": "Bad Request", + "message": "ERROR: [400] [ + <<<'MANAGED_SERVER:vm1400.config.extra' is not expected but is set to '42', + <<<'MANAGED_SERVER:vm1400.config.monit_max_cpu_usage' is expected to be at most 100 but is 101, + <<<'MANAGED_SERVER:vm1400.config.monit_max_ssd_usage' is expected to be at least 10 but is 0 + <<<]" + } + """.replaceAll(" +<<<", ""))); // @formatter:on + } + + @Test + void totalsLimitValidationsArePerformend_whenAddingAsset() { + + context.define("superuser-alex@hostsharing.net"); + final var givenHostingAsset = givenRealHostingAsset(MANAGED_WEBSPACE, "fir01"); + assertThat(givenHostingAsset.getBookingItem().getResources().get("Multi")) + .as("precondition failed") + .isEqualTo(1); + final var preExistingUnixUserCount = realAssetRepo.findAllByCriteria(null, givenHostingAsset.getUuid(), UNIX_USER).size(); + final var UNIX_USER_PER_MULTI_OPTION = 25; + + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + for (int n = 0; n < UNIX_USER_PER_MULTI_OPTION-preExistingUnixUserCount; ++n) { + toCleanup(realAssetRepo.save( + HsHostingAssetRealEntity.builder() + .type(UNIX_USER) + .parentAsset(givenHostingAsset) + .identifier("fir01-%2d".formatted(n)) + .caption("Test UnixUser fir01-%2d".formatted(n)) + .build())); + } + }).assertSuccessful(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "parentAssetUuid": "%s", + "type": "UNIX_USER", + "identifier": "fir01-extra", + "caption": "some extra UnixUser", + "config": { } + } + """.formatted(givenHostingAsset.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(400) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "statusPhrase": "Bad Request", + "message": "ERROR: [400] ['D-1000111:D-1000111 default project:separate ManagedWebspace.resources.Multi=1 allows at maximum 25 unix users, but 26 found]" + } + """.replaceAll(" +<<<", ""))); // @formatter:on + } + } + + @Nested + @Order(1) + class GetAsset { + + @Test + void globalAdmin_canGetArbitraryAsset() { + context.define("superuser-alex@hostsharing.net"); + final var givenAssetUuid = realAssetRepo.findByIdentifier("vm1011").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000111 default project")) + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/hosting/assets/" + givenAssetUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "caption": "some ManagedServer", + "config": {} + } + """)); // @formatter:on + } + + @Test + void normalUser_canNotGetUnrelatedAsset() { + context.define("superuser-alex@hostsharing.net"); + final var givenAssetUuid = realAssetRepo.findByIdentifier("vm1012").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000212 default project")) + .map(HsHostingAssetRealEntity::getUuid) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/hosting/assets/" + givenAssetUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + void projectAgentUser_canGetRelatedAsset() { + context.define("superuser-alex@hostsharing.net"); + final var givenAssetUuid = realAssetRepo.findByIdentifier("vm1013").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000313 default project")) + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "person-TuckerJack@example.com") + .header("assumed-roles", "hs_booking_project#D-1000313-D-1000313defaultproject:AGENT") + .port(port) + .when() + .get("http://localhost/api/hs/hosting/assets/" + givenAssetUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "identifier": "vm1013", + "caption": "some ManagedServer", + "config": {} + } + """)); // @formatter:on + } + } + + @Nested + @Order(4) + class PatchAsset { + + @Test + void globalAdmin_canPatchAllUpdatablePropertiesOfAsset() { + + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetRealEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm2001") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); + final var alarmContactUuid = givenContact().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "alarmContactUuid": "%s", + "config": { + "monit_max_ssd_usage": 85, + "monit_max_hdd_usage": null, + "monit_min_free_ssd": 5 + } + } + """.formatted(alarmContactUuid)) + .port(port) + .when() + .patch("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "identifier": "vm2001", + "caption": "some test-asset", + "alarmContact": { + "caption": "second contact", + "emailAddresses": { + "main": "contact-admin@secondcontact.example.com" + } + }, + "config": { + "monit_max_cpu_usage": 90, + "monit_max_ram_usage": 70, + "monit_max_ssd_usage": 85, + "monit_min_free_ssd": 5 + } + } + """)); + // @formatter:on + + // finally, the asset is actually updated + em.clear(); + assertThat(realAssetRepo.findByUuid(givenAsset.getUuid())).isPresent().get() + .matches(asset -> { + assertThat(asset.getAlarmContact()).isNotNull() + .extracting(c -> c.getEmailAddresses().get("main")) + .isEqualTo("contact-admin@secondcontact.example.com"); + assertThat(asset.getConfig().toString()) + .isEqualToIgnoringWhitespace(""" + { + "monit_max_cpu_usage": 90, + "monit_max_ram_usage": 70, + "monit_max_ssd_usage": 85, + "monit_min_free_ssd": 5 + } + """); + return true; + }); + } + + @Test + void assetAdmin_canPatchAllUpdatablePropertiesOfAsset() { + + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetRealEntity.builder() + .uuid(UUID.randomUUID()) + .type(UNIX_USER) + .parentAsset(givenRealHostingAsset(MANAGED_WEBSPACE, "fir01")) + .identifier("fir01-temp") + .caption("some test-unix-user") + .build()); + HashGenerator.nextSalt("Jr5w/Y8zo8pCkqg7"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + //.header("assumed-roles", "hs_hosting_asset#vm2001:ADMIN") + .contentType(ContentType.JSON) + .body(""" + { + "caption" : "some patched test-unix-user", + "config": { + "shell": "/bin/bash", + "totpKey": "0x1234567890abcdef0123456789abcdef", + "password": "Ein Passwort mit 4 Zeichengruppen!" + } + } + """) + .port(port) + .when() + .patch("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some patched test-unix-user", + "config": { + "homedir": "/home/pacs/fir01/users/temp", + "shell": "/bin/bash" + } + } + """)) + // the config separately but not-leniently to make sure that no write-only-properties are listed + .body("config", strictlyEquals(""" + { + "homedir": "/home/pacs/fir01/users/temp", + "shell": "/bin/bash" + } + """)) + ; + // @formatter:on + + // finally, the asset is actually updated + assertThat(jpaAttempt.transacted(() -> { + return realAssetRepo.findByUuid(givenAsset.getUuid()); + }).returnedValue()).isPresent().get() + .matches(asset -> { + assertThat(asset.getCaption()).isEqualTo("some patched test-unix-user"); + assertThat(asset.getConfig().toString()).isEqualToIgnoringWhitespace(""" + { + "password": "$6$Jr5w/Y8zo8pCkqg7$/rePRbvey3R6Sz/02YTlTQcRt5qdBPTj2h5.hz.rB8NfIoND8pFOjeB7orYcPs9JNf3JDxPP2V.6MQlE5BwAY/", + "shell": "/bin/bash", + "totpKey": "0x1234567890abcdef0123456789abcdef" + } + """); + return true; + }); + } + } + + @Nested + @Order(5) + class DeleteAsset { + + @Test + void globalAdmin_canDeleteArbitraryAsset() { + context.define("superuser-alex@hostsharing.net"); + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetRealEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm1002") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given assets is gone + assertThat(realAssetRepo.findByUuid(givenAsset.getUuid())).isEmpty(); + } + + @Test + void normalUser_canNotDeleteUnrelatedAsset() { + context.define("superuser-alex@hostsharing.net"); + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetRealEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm1003") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) + .then().log().all().assertThat() + .statusCode(404); // @formatter:on + + // then the given asset is still there + assertThat(realAssetRepo.findByUuid(givenAsset.getUuid())).isNotEmpty(); + } + } + + HsHostingAssetRealEntity givenRealHostingAsset(final HsHostingAssetType type, final String identifier) { + return realAssetRepo.findByIdentifier(identifier).stream() + .filter(ha -> ha.getType() == type) + .findAny().orElseThrow(); + } + + HsBookingItem newBookingItem( + final String projectCaption, + final HsBookingItemType type, final String bookingItemCaption, final Map resources) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var project = realProjectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); + final var bookingItem = HsBookingItemRealEntity.builder() + .project(project) + .type(type) + .caption(bookingItemCaption) + .resources(resources) + .build(); + return toCleanup(realBookingItemRepo.save(bookingItem)); + }).assertSuccessful().returnedValue(); + } + + HsBookingItemRealEntity givenSomeNewBookingItem( + final String projectCaption, + final HsBookingItemType bookingItemType, + final String bookingItemCaption) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var project = realProjectRepo.findByCaption(projectCaption).getFirst(); + final var resources = switch (bookingItemType) { + case MANAGED_SERVER -> Map.ofEntries(entry("CPU", 1), + entry("RAM", 20), + entry("SSD", 25), + entry("Traffic", 250)); + default -> new HashMap(); + }; + final var newBookingItem = HsBookingItemRealEntity.builder() + .project(project) + .type(bookingItemType) + .caption(bookingItemCaption) + .resources(resources) + .build(); + return toCleanup(realBookingItemRepo.save(newBookingItem)); + }).assertSuccessful().returnedValue(); + } + + private HsBookingItemRealEntity givenSomeTemporaryBookingItem(final Supplier newBookingItem) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); // needed to determine creator + return toCleanup(realBookingItemRepo.save(newBookingItem.get())); + }).assertSuccessful().returnedValue(); + } + + HsHostingAssetRealEntity givenParentAsset(final HsHostingAssetType assetType, final String assetIdentifier) { + final var givenAsset = realAssetRepo.findByIdentifier(assetIdentifier).stream() + .filter(a -> a.getType() == assetType) + .findAny().orElseThrow(); + return givenAsset; + } + + private HsHostingAssetRealEntity givenSomeTemporaryHostingAsset(final Supplier newAsset) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); // needed to determine creator + return toCleanup(realAssetRepo.save(newAsset.get())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficeContactRealEntity givenContact() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); // needed to determine creator + return realContactRepo.findContactByOptionalCaptionLike("second").stream().findFirst().orElseThrow(); + }).returnedValue(); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java new file mode 100644 index 00000000..ff2da459 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java @@ -0,0 +1,732 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.SynchronizationType; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_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.HsHostingAssetTestEntities.MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealTestEntity.TEST_REAL_CONTACT; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsHostingAssetController.class) +@Import({Mapper.class, JsonObjectMapperConfiguration.class}) +@RunWith(SpringRunner.class) +public class HsHostingAssetControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @Autowired + Mapper mapper; + + @MockBean + private EntityManagerWrapper em; + + @MockBean + EntityManagerFactory emf; + + @MockBean + @SuppressWarnings("unused") // bean needs to be present for HsHostingAssetController + private HsBookingItemRealRepository realBookingItemRepo; + + @MockBean + private HsHostingAssetRealRepository realAssetRepo; + + @MockBean + private HsHostingAssetRbacRepository rbacAssetRepo; + + @TestConfiguration + public static class TestConfig { + + @Bean + public EntityManager entityManager() { + return mock(EntityManager.class); + } + + } + enum ListTestCases { + CLOUD_SERVER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.CLOUD_SERVER) + .bookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY) + .identifier("vm1234") + .caption("some fake cloud-server") + .alarmContact(TEST_REAL_CONTACT) + .build()), + """ + [ + { + "type": "CLOUD_SERVER", + "identifier": "vm1234", + "caption": "some fake cloud-server", + "alarmContact": { + "caption": "some contact", + "postalAddress": "address of some contact", + "emailAddresses": { + "main": "some-contact@example.com" + } + }, + "config": {} + } + ] + """), + MANAGED_SERVER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .identifier("vm1234") + .caption("some fake managed-server") + .alarmContact(TEST_REAL_CONTACT) + .config(Map.ofEntries( + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) + )) + .build()), + """ + [ + { + "type": "MANAGED_SERVER", + "identifier": "vm1234", + "caption": "some fake managed-server", + "alarmContact": { + "caption": "some contact", + "postalAddress": "address of some contact", + "emailAddresses": { + "main": "some-contact@example.com" + } + }, + "config": { + "monit_max_ssd_usage": 70, + "monit_max_cpu_usage": 80, + "monit_max_ram_usage": 90 + } + } + ] + """), + UNIX_USER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .caption("some fake Unix-User") + .config(Map.ofEntries( + entry("password", "$6$salt$hashed-salted-password"), + entry("totpKey", "0x0123456789abcdef"), + entry("shell", "/bin/bash"), + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build()), + """ + [ + { + "type": "UNIX_USER", + "identifier": "xyz00-office", + "caption": "some fake Unix-User", + "alarmContact": null, + "config": { + "SSD-soft-quota": 128, + "SSD-hard-quota": 256, + "HDD-soft-quota": 256, + "HDD-hard-quota": 512, + "shell": "/bin/bash", + "homedir": "/home/pacs/xyz00/users/office" + } + } + ] + """), + EMAIL_ALIAS( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.EMAIL_ALIAS) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .caption("some fake EMail-Alias") + .config(Map.ofEntries( + entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) + )) + .build()), + """ + [ + { + "type": "EMAIL_ALIAS", + "identifier": "xyz00-office", + "caption": "some fake EMail-Alias", + "alarmContact": null, + "config": { + "target": ["xyz00","xyz00-abc","office@example.com"] + } + } + ] + """), + DOMAIN_SETUP( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_SETUP) + .identifier("example.org") + .caption("some fake Domain-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_SETUP", + "identifier": "example.org", + "caption": "some fake Domain-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + DOMAIN_DNS_SETUP( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_DNS_SETUP) + .identifier("example.org") + .caption("some fake Domain-DNS-Setup") + .config(Map.ofEntries( + entry("auto-WILDCARD-MX-RR", false), + entry("auto-WILDCARD-A-RR", false), + entry("auto-WILDCARD-AAAA-RR", false), + entry("auto-WILDCARD-DKIM-RR", false), + entry("auto-WILDCARD-SPF-RR", false), + entry("user-RR", Array.of( + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com") + ) + )) + .build()), + """ + [ + { + "type": "DOMAIN_DNS_SETUP", + "identifier": "example.org", + "caption": "some fake Domain-DNS-Setup", + "alarmContact": null, + "config": { + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "auto-WILDCARD-DKIM-RR": false, + "auto-WILDCARD-A-RR": false, + "user-RR": [ + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com" + ] + } + } + ] + """), + DOMAIN_HTTP_SETUP( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .identifier("example.org|HTTP") + .caption("some fake Domain-HTTP-Setup") + .config(Map.ofEntries( + entry("htdocsfallback", false), + entry("indexes", false), + entry("cgi", false), + entry("passenger", false), + entry("passenger-errorpage", true), + entry("fastcgi", false), + entry("autoconfig", false), + entry("greylisting", false), + entry("includes", false), + entry("letsencrypt", false), + entry("multiviews", false), + entry("fcgi-php-bin", "/usr/lib/cgi-bin/php8"), + entry("passenger-nodejs", "/usr/bin/node-js7"), + entry("passenger-python", "/usr/bin/python6"), + entry("passenger-ruby", "/usr/bin/ruby5"), + entry("subdomains", Array.of("www", "test1", "test2")) + )) + .build()), + """ + [ + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "example.org|HTTP", + "caption": "some fake Domain-HTTP-Setup", + "alarmContact": null, + "config": { + "autoconfig": false, + "cgi": false, + "fastcgi": false, + "greylisting": false, + "htdocsfallback": false, + "includes": false, + "indexes": false, + "letsencrypt": false, + "multiviews": false, + "passenger": false, + "passenger-errorpage": true, + "passenger-nodejs": "/usr/bin/node-js7", + "passenger-python": "/usr/bin/python6", + "passenger-ruby": "/usr/bin/ruby5", + "fcgi-php-bin": "/usr/lib/cgi-bin/php8", + "subdomains": ["www","test1","test2"] + } + } + ] + """), + DOMAIN_SMTP_SETUP( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_SMTP_SETUP) + .identifier("example.org|SMTP") + .caption("some fake Domain-SMTP-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_SMTP_SETUP", + "identifier": "example.org|SMTP", + "caption": "some fake Domain-SMTP-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + DOMAIN_MBOX_SETUP( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_MBOX_SETUP) + .identifier("example.org|MBOX") + .caption("some fake Domain-MBOX-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_MBOX_SETUP", + "identifier": "example.org|MBOX", + "caption": "some fake Domain-MBOX-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + EMAIL_ADDRESS( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.EMAIL_ADDRESS) + .parentAsset(HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.DOMAIN_MBOX_SETUP) + .identifier("example.org|MBOX") + .caption("some fake Domain-MBOX-Setup") + .build()) + .identifier("office@example.org") + .caption("some fake EMail-Address") + .config(Map.ofEntries( + entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) + )) + .build()), + """ + [ + { + "type": "EMAIL_ADDRESS", + "identifier": "office@example.org", + "caption": "some fake EMail-Address", + "alarmContact": null, + "config": { + "target": ["xyz00","xyz00-abc","office@example.com"] + } + } + ] + """), + MARIADB_INSTANCE( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MARIADB_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|MariaDB.default") + .caption("some fake MariaDB instance") + .build()), + """ + [ + { + "type": "MARIADB_INSTANCE", + "identifier": "vm1234|MariaDB.default", + "caption": "some fake MariaDB instance", + "alarmContact": null, + "config": {} + } + ] + """), + MARIADB_USER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MARIADB_USER) + .identifier("xyz00_temp") + .caption("some fake MariaDB user") + .build()), + """ + [ + { + "type": "MARIADB_USER", + "identifier": "xyz00_temp", + "caption": "some fake MariaDB user", + "alarmContact": null, + "config": {} + } + ] + """), + MARIADB_DATABASE( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MARIADB_DATABASE) + .identifier("xyz00_temp") + .caption("some fake MariaDB database") + .config(Map.ofEntries( + entry("encoding", "latin1"), + entry("collation", "latin2") + )) + .build()), + """ + [ + { + "type": "MARIADB_DATABASE", + "identifier": "xyz00_temp", + "caption": "some fake MariaDB database", + "alarmContact": null, + "config": { + "encoding": "latin1", + "collation": "latin2" + } + } + ] + """), + PGSQL_INSTANCE( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.PGSQL_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|PgSql.default") + .caption("some fake PgSql instance") + .build()), + """ + [ + { + "type": "PGSQL_INSTANCE", + "identifier": "vm1234|PgSql.default", + "caption": "some fake PgSql instance", + "alarmContact": null, + "config": {} + } + ] + """), + PGSQL_USER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.PGSQL_USER) + .identifier("xyz00_temp") + .caption("some fake PgSql user") + .build()), + """ + [ + { + "type": "PGSQL_USER", + "identifier": "xyz00_temp", + "caption": "some fake PgSql user", + "alarmContact": null, + "config": {} + } + ] + """), + PGSQL_DATABASE( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.PGSQL_DATABASE) + .identifier("xyz00_temp") + .caption("some fake PgSql database") + .config(Map.ofEntries( + entry("encoding", "latin1"), + entry("collation", "latin2") + )) + .build()), + """ + [ + { + "type": "PGSQL_DATABASE", + "identifier": "xyz00_temp", + "caption": "some fake PgSql database", + "alarmContact": null, + "config": { + "encoding": "latin1", + "collation": "latin2" + } + } + ] + """), + IPV4_NUMBER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.IPV4_NUMBER) + .assignedToAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("11.12.13.14") + .caption("some fake IPv4 number") + .build()), + """ + [ + { + "type": "IPV4_NUMBER", + "identifier": "11.12.13.14", + "caption": "some fake IPv4 number", + "alarmContact": null, + "config": {} + } + ] + """), + IPV6_NUMBER( + List.of( + HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.IPV6_NUMBER) + .assignedToAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("2001:db8:3333:4444:5555:6666:7777:8888") + .caption("some fake IPv6 number") + .build()), + """ + [ + { + "type": "IPV6_NUMBER", + "identifier": "2001:db8:3333:4444:5555:6666:7777:8888", + "caption": "some fake IPv6 number", + "alarmContact": null, + "config": {} + } + ] + """); + + final HsHostingAssetType assetType; + final List givenHostingAssetsOfType; + final String expectedResponse; + final JsonNode expectedResponseJson; + + @SneakyThrows + ListTestCases( + final List givenHostingAssetsOfType, + final String expectedResponse) { + this.assetType = HsHostingAssetType.valueOf(name()); + this.givenHostingAssetsOfType = givenHostingAssetsOfType; + this.expectedResponse = expectedResponse; + this.expectedResponseJson = new ObjectMapper().readTree(expectedResponse); + } + + @SneakyThrows + JsonNode expectedConfig(final int n) { + return expectedResponseJson.get(n).path("config"); + } + } + + @BeforeEach + void init() { + when(emf.createEntityManager()).thenReturn(em); + when(emf.createEntityManager(any(Map.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em); + } + + @ParameterizedTest + @EnumSource(HsHostingAssetControllerRestTest.ListTestCases.class) + void shouldListAssets(final HsHostingAssetControllerRestTest.ListTestCases testCase) throws Exception { + // given + when(rbacAssetRepo.findAllByCriteria(null, null, testCase.assetType)) + .thenReturn(testCase.givenHostingAssetsOfType); + + // when + final var result = mockMvc.perform(MockMvcRequestBuilders + .get("/api/hs/hosting/assets?type="+testCase.name()) + .header("current-user", "superuser-alex@hostsharing.net") + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$", lenientlyEquals(testCase.expectedResponse))) + .andReturn(); + + // and the config properties do match not just leniently but even strictly + final var resultBody = new ObjectMapper().readTree(result.getResponse().getContentAsString()); + for (int n = 0; n < resultBody.size(); ++n) { + assertThat(resultBody.get(n).path("config")).isEqualTo(testCase.expectedConfig(n)); + } + } + + @Test + void shouldPatchAsset() throws Exception { + // given + final var givenDomainSetup = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.DOMAIN_SETUP) + .identifier("example.org") + .caption("some fake Domain-Setup") + .build(); + final var givenUnixUser = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .caption("some fake Unix-User") + .config(Map.ofEntries( + entry("password", "$6$salt$hashed-salted-password"), + entry("totpKey", "0x0123456789abcdef"), + entry("shell", "/bin/bash"), + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build(); + final var givenDomainHttpSetupUuid = UUID.randomUUID(); + final var givenDomainHttpSetupHostingAsset = HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .identifier("example.org|HTTP") + .caption("some fake Domain-HTTP-Setup") + .parentAsset(givenDomainSetup) + .assignedToAsset(givenUnixUser) + .config(new HashMap<>(Map.ofEntries( + entry("htdocsfallback", false), + entry("indexes", false), + entry("cgi", false), + entry("passenger", false), + entry("passenger-errorpage", true), + entry("fastcgi", false), + entry("autoconfig", false), + entry("greylisting", false), + entry("includes", false), + entry("letsencrypt", false), + entry("multiviews", false), + entry("fcgi-php-bin", "/usr/lib/cgi-bin/php-orig"), + entry("passenger-nodejs", "/usr/bin/node-js7"), + entry("passenger-python", "/usr/bin/python6"), + entry("passenger-ruby", "/usr/bin/ruby5"), + entry("subdomains", Array.of("www", "test1", "test2")) + ))) + .build(); + when(rbacAssetRepo.findByUuid(givenDomainHttpSetupUuid)).thenReturn(Optional.of(givenDomainHttpSetupHostingAsset)); + when(em.contains(givenDomainHttpSetupHostingAsset)).thenReturn(true); + doNothing().when(em).flush(); + + // when + final var result = mockMvc.perform(MockMvcRequestBuilders + .patch("/api/hs/hosting/assets/" + givenDomainHttpSetupUuid) + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "updated example.org|HTTP", + "caption": "some updated fake Domain-HTTP-Setup", + "alarmContact": null, + "config": { + "autoconfig": true, + "multiviews": true, + "passenger": false, + "fcgi-php-bin": null, + "passenger-nodejs": "/usr/bin/node-js8", + "subdomains": ["www","test"] + } + } + """) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "example.org|HTTP", + "caption": "some updated fake Domain-HTTP-Setup", + "alarmContact": null + } + """))) + .andReturn(); + + // and the config properties do match not just leniently but even strictly + final var actualConfig = formatJsonNode(result.getResponse().getContentAsString()); + final var expectedConfig = formatJsonNode(""" + { + "config": { + "autoconfig" : true, + "cgi" : false, + "fastcgi" : false, + // "fcgi-php-bin" : "/usr/lib/cgi-bin/php", TODO.spec: do we want defaults to work like initializers? + "greylisting" : false, + "htdocsfallback" : false, + "includes" : false, + "indexes" : false, + "letsencrypt" : false, + "multiviews" : true, + "passenger" : false, + "passenger-errorpage" : true, + "passenger-nodejs" : "/usr/bin/node-js8", + "passenger-python" : "/usr/bin/python6", + "passenger-ruby" : "/usr/bin/ruby5", + "subdomains" : [ "www", "test" ] + } + } + """); + assertThat(actualConfig).isEqualTo(expectedConfig); + } + + private static final ObjectMapper SORTED_MAPPER = new ObjectMapper(); + static { + SORTED_MAPPER.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + private static String formatJsonNode(final String json) throws JsonProcessingException { + final var node = SORTED_MAPPER.readTree(json.replaceAll("//.*", "")).path("config"); + final var obj = SORTED_MAPPER.treeToValue(node, Object.class); + return SORTED_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(obj); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java new file mode 100644 index 00000000..51020b16 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java @@ -0,0 +1,117 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.mapper.KeyValueMap; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< + HsHostingAssetPatchResource, + HsHostingAssetRbacEntity + > { + + private static final UUID INITIAL_BOOKING_ITEM_UUID = UUID.randomUUID(); + private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); + + private static final Map INITIAL_CONFIG = patchMap( + entry("CPU", 1), + entry("HDD", 1024), + entry("MEM", 64) + ); + private static final Map PATCH_CONFIG = patchMap( + entry("CPU", 2), + entry("HDD", null), + entry("SSD", 256) + ); + private static final Map PATCHED_CONFIG = patchMap( + entry("CPU", 2), + entry("SSD", 256), + entry("MEM", 64) + ); + final HsOfficeContactRealEntity givenInitialContact = HsOfficeContactRealEntity.builder() + .uuid(UUID.randomUUID()) + .build(); + + private static final String INITIAL_CAPTION = "initial caption"; + private static final String PATCHED_CAPTION = "patched caption"; + + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsHostingAssetRbacEntity.class), any())).thenAnswer(invocation -> + HsHostingAssetRbacEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeContactRealEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactRealEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + @Override + protected HsHostingAssetRbacEntity newInitialEntity() { + final var entity = new HsHostingAssetRbacEntity(); + entity.setUuid(INITIAL_BOOKING_ITEM_UUID); + entity.setBookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY); + entity.getConfig().putAll(KeyValueMap.from(INITIAL_CONFIG)); + entity.setCaption(INITIAL_CAPTION); + entity.setAlarmContact(givenInitialContact); + return entity; + } + + @Override + protected HsHostingAssetPatchResource newPatchResource() { + return new HsHostingAssetPatchResource(); + } + + @Override + protected HsHostingAssetEntityPatcher createPatcher(final HsHostingAssetRbacEntity server) { + return new HsHostingAssetEntityPatcher(em, server); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "caption", + HsHostingAssetPatchResource::setCaption, + PATCHED_CAPTION, + HsHostingAssetRbacEntity::setCaption), + new SimpleProperty<>( + "config", + HsHostingAssetPatchResource::setConfig, + PATCH_CONFIG, + HsHostingAssetRbacEntity::putConfig, + PATCHED_CONFIG) + .notNullable(), + new JsonNullableProperty<>( + "alarmContact", + HsHostingAssetPatchResource::setAlarmContactUuid, + PATCHED_CONTACT_UUID, + HsHostingAssetRbacEntity::setAlarmContact, + newContact(PATCHED_CONTACT_UUID)) + ); + } + + static HsOfficeContactRealEntity newContact(final UUID uuid) { + return HsOfficeContactRealEntity.builder().uuid(uuid).build(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java new file mode 100644 index 00000000..4fe581e3 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java @@ -0,0 +1,77 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static org.assertj.core.api.Assertions.assertThat; + +class HsHostingAssetEntityUnitTest { + + final HsHostingAssetRealEntity givenParentAsset = HsHostingAssetRealEntity.builder() + .bookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY) + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed asset") + .config(Map.ofEntries( + entry("CPU", 2), + entry("SSD-storage", 512), + entry("HDD-storage", 2048))) + .build(); + final HsHostingAssetRealEntity givenWebspace = HsHostingAssetRealEntity.builder() + .bookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY) + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .parentAsset(givenParentAsset) + .identifier("xyz00") + .caption("some managed webspace") + .config(Map.ofEntries( + entry("CPU", 2), + entry("SSD-storage", 512), + entry("HDD-storage", 2048))) + .build(); + final HsHostingAssetRealEntity givenUnixUser = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(givenWebspace) + .identifier("xyz00-web") + .caption("some unix-user") + .config(Map.ofEntries( + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build(); + final HsHostingAssetRealEntity givenDomainHttpSetup = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .parentAsset(givenWebspace) + .identifier("example.org") + .assignedToAsset(givenUnixUser) + .caption("some domain setup") + .config(Map.ofEntries( + entry("option-htdocsfallback", true), + entry("use-fcgiphpbin", "/usr/lib/cgi-bin/php"), + entry("validsubdomainnames", "*"))) + .build(); + + @Test + void toStringContainsAllPropertiesAndResourcesSortedByKey() { + + assertThat(givenWebspace.toString()).isEqualToIgnoringWhitespace( + "HsHostingAsset(MANAGED_WEBSPACE, xyz00, some managed webspace, MANAGED_SERVER:vm1234, D-1234500:test project:test cloud server booking item, { \"CPU\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })"); + + assertThat(givenUnixUser.toString()).isEqualToIgnoringWhitespace( + "HsHostingAsset(UNIX_USER, xyz00-web, some unix-user, MANAGED_WEBSPACE:xyz00, { \"HDD-hard-quota\": 512, \"HDD-soft-quota\": 256, \"SSD-hard-quota\": 256, \"SSD-soft-quota\": 128 })"); + + assertThat(givenDomainHttpSetup.toString()).isEqualToIgnoringWhitespace( + "HsHostingAsset(DOMAIN_HTTP_SETUP, example.org, some domain setup, MANAGED_WEBSPACE:xyz00, UNIX_USER:xyz00-web, { \"option-htdocsfallback\": true, \"use-fcgiphpbin\": \"/usr/lib/cgi-bin/php\", \"validsubdomainnames\": \"*\" })"); + } + + @Test + void toShortStringContainsOnlyMemberNumberAndCaption() { + + assertThat(givenWebspace.toShortString()).isEqualTo("MANAGED_WEBSPACE:xyz00"); + assertThat(givenUnixUser.toShortString()).isEqualTo("UNIX_USER:xyz00-web"); + assertThat(givenDomainHttpSetup.toShortString()).isEqualTo("DOMAIN_HTTP_SETUP:example.org"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java new file mode 100644 index 00000000..6b9188e6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java @@ -0,0 +1,210 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import io.restassured.RestAssured; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +class HsHostingAssetPropsControllerAcceptanceTest { + + @LocalServerPort + private Integer port; + + @Test + void anyone_canListAvailableAssetTypes() { + + RestAssured // @formatter:off + .given() + .port(port) + .when() + .get("http://localhost/api/hs/hosting/asset-types") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + "MANAGED_SERVER", + "MANAGED_WEBSPACE", + "CLOUD_SERVER", + "UNIX_USER", + "EMAIL_ALIAS", + "DOMAIN_SETUP", + "DOMAIN_DNS_SETUP", + "DOMAIN_HTTP_SETUP", + "DOMAIN_SMTP_SETUP", + "DOMAIN_MBOX_SETUP", + "EMAIL_ADDRESS", + "MARIADB_INSTANCE", + "MARIADB_USER", + "MARIADB_DATABASE", + "PGSQL_INSTANCE", + "PGSQL_USER", + "PGSQL_DATABASE", + "IPV4_NUMBER", + "IPV6_NUMBER" + ] + """)); + // @formatter:on + } + + @Test + void anyone_canListPropertiesOfGivenAssetType() { + + RestAssured // @formatter:off + .given() + .port(port) + .when() + .get("http://localhost/api/hs/hosting/asset-types/" + HsHostingAssetType.MANAGED_SERVER) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "type": "integer", + "propertyName": "monit_max_cpu_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 92 + }, + { + "type": "integer", + "propertyName": "monit_max_ram_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 92 + }, + { + "type": "integer", + "propertyName": "monit_max_ssd_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 98 + }, + { + "type": "integer", + "propertyName": "monit_min_free_ssd", + "min": 1, + "max": 1000, + "defaultValue": 5 + }, + { + "type": "integer", + "propertyName": "monit_max_hdd_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 95 + }, + { + "type": "integer", + "propertyName": "monit_min_free_hdd", + "min": 1, + "max": 4000, + "defaultValue": 10 + }, + { + "type": "boolean", + "propertyName": "software-pgsql", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-mariadb", + "defaultValue": true + }, + { + "type": "enumeration", + "propertyName": "php-default", + "values": [ + "5.6", + "7.0", + "7.1", + "7.2", + "7.3", + "7.4", + "8.0", + "8.1", + "8.2" + ], + "defaultValue": "8.2" + }, + { + "type": "boolean", + "propertyName": "software-php-5.6" + }, + { + "type": "boolean", + "propertyName": "software-php-7.0" + }, + { + "type": "boolean", + "propertyName": "software-php-7.1" + }, + { + "type": "boolean", + "propertyName": "software-php-7.2" + }, + { + "type": "boolean", + "propertyName": "software-php-7.3" + }, + { + "type": "boolean", + "propertyName": "software-php-7.4", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-php-8.0" + }, + { + "type": "boolean", + "propertyName": "software-php-8.1" + }, + { + "type": "boolean", + "propertyName": "software-php-8.2", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-postfix-tls-1.0" + }, + { + "type": "boolean", + "propertyName": "software-dovecot-tls-1.0" + }, + { + "type": "boolean", + "propertyName": "software-clamav", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-collabora" + }, + { + "type": "boolean", + "propertyName": "software-libreoffice" + }, + { + "type": "boolean", + "propertyName": "software-imagemagick-ghostscript" + } + ] + """)); + // @formatter:on + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java new file mode 100644 index 00000000..26861624 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java @@ -0,0 +1,541 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRbacRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.JpaSystemException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ Context.class, JpaAttempt.class }) +class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + HsHostingAssetRealRepository realAssetRepo; + + @Autowired + HsHostingAssetRbacRepository rbacAssetRepo; + + @Autowired + HsBookingItemRealRepository realBookingItemRepo; + + @Autowired + HsBookingProjectRbacRepository projectRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @MockBean + HttpServletRequest request; + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select currentTask, targetTable, targetOp, targetdelta->>'caption' + from tx_journal_v + where targettable = 'hs_hosting_asset'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, another CloudServer]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Domain-DNS-Setup]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Domain-HTTP-Setup]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Domain-MBOX-Setup]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Domain-SMTP-Setup]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Domain-Setup]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some E-Mail-Address]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some E-Mail-Alias]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some ManagedServer]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some UnixUser for E-Mail]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some UnixUser for Website]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some Webspace]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default MariaDB instance]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default MariaDB user]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default MariaDB database]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default Postgresql instance]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default Postgresql user]", + "[creating hosting-asset test-data, hs_hosting_asset, INSERT, some default Postgresql database]" + ); + } + + @Test + public void historizationIsAvailable() { + // given + final String nativeQuerySql = """ + select count(*) + from hs_hosting_asset_hv ha; + """; + + // when + historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); + final var query = em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + + // then + assertThat(countBefore).as("hs_hosting_asset_hv should not contain rows for a timestamp in the past").isEqualTo(0); + + // and when + historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); + em.createNativeQuery(nativeQuerySql, Integer.class); + @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + + // then + assertThat(countAfter).as("hs_hosting_asset_hv should contain rows for a timestamp in the future").isGreaterThan(1); + } + + @Nested + class CreateAsset { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewAsset() { + // given + context("superuser-alex@hostsharing.net"); // TODO.test: remove context(...) once all entities have real entities + final var count = realAssetRepo.count(); + final var givenManagedServer = givenHostingAsset("D-1000111 default project", MANAGED_SERVER); + final var newWebspaceBookingItem = newBookingItem(givenManagedServer.getBookingItem(), HsBookingItemType.MANAGED_WEBSPACE, "fir01"); + + // when + final var result = attempt(em, () -> { + final var newAsset = HsHostingAssetRbacEntity.builder() + .bookingItem(newWebspaceBookingItem) + .parentAsset(givenManagedServer) + .caption("some new managed webspace") + .type(MANAGED_WEBSPACE) + .identifier("xyz90") + .build(); + return toCleanup(rbacAssetRepo.save(newAsset)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsHostingAssetRbacEntity::getUuid).isNotNull(); + assertThatAssetIsPersisted(result.returnedValue()); + assertThat(result.returnedValue().isLoaded()).isFalse(); + assertThat(realAssetRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + // TODO.test: remove context(...) once all entities have real entities + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var givenManagedServer = givenHostingAsset("D-1000111 default project", MANAGED_SERVER); + final var newWebspaceBookingItem = newBookingItem(givenManagedServer.getBookingItem(), HsBookingItemType.MANAGED_WEBSPACE, "fir01"); + em.flush(); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); + + // when + context("superuser-alex@hostsharing.net", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var result = attempt(em, () -> { + final var newAsset = HsHostingAssetRbacEntity.builder() + .bookingItem(newWebspaceBookingItem) + .parentAsset(givenManagedServer) + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("fir00") + .caption("some new managed webspace") + .build(); + return toCleanup(rbacAssetRepo.save(newAsset)); + }); + + // then + result.assertSuccessful(); + final var all = rawRoleRepo.findAll(); + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_hosting_asset#fir00:ADMIN", + "hs_hosting_asset#fir00:AGENT", + "hs_hosting_asset#fir00:OWNER", + "hs_hosting_asset#fir00:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) + .containsExactlyInAnyOrder(fromFormatted( + initialGrantNames, + + // global-admin + "{ grant role:hs_hosting_asset#fir00:OWNER to role:global#global:ADMIN by system }", // workaround + + // owner + "{ grant role:hs_hosting_asset#fir00:OWNER to user:superuser-alex@hostsharing.net by hs_hosting_asset#fir00:OWNER and assume }", + "{ grant role:hs_hosting_asset#fir00:OWNER to role:hs_booking_item#fir01:ADMIN by system and assume }", + "{ grant role:hs_hosting_asset#fir00:OWNER to role:hs_hosting_asset#vm1011:ADMIN by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:DELETE to role:hs_hosting_asset#fir00:OWNER by system and assume }", + + // admin + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_hosting_asset#fir00:OWNER by system and assume }", + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_booking_item#fir01:AGENT by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:UPDATE to role:hs_hosting_asset#fir00:ADMIN by system and assume }", + + // agent + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_hosting_asset#vm1011:AGENT by system and assume }", + "{ grant role:hs_hosting_asset#fir00:AGENT to role:hs_hosting_asset#fir00:ADMIN by system and assume }", + + // tenant + "{ grant role:hs_booking_item#fir01:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }", + "{ grant role:hs_hosting_asset#fir00:TENANT to role:hs_hosting_asset#fir00:AGENT by system and assume }", + "{ grant role:hs_hosting_asset#vm1011:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:SELECT to role:hs_hosting_asset#fir00:TENANT by system and assume }", + + null)); + } + + @Test + public void anyUser_canCreateNewDomainSetupAsset() { + // when + context("person-SmithPeter@example.com"); + final var result = attempt(em, () -> { + final var newAsset = HsHostingAssetRbacEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.net") + .caption("some new domain setup") + .build(); + return rbacAssetRepo.save(newAsset); + }); + + // then + // ... the domain setup was created and returned + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsHostingAssetRbacEntity::getUuid).isNotNull(); + assertThat(result.returnedValue().isLoaded()).isFalse(); + + // ... the creating user can read the new domain setup + context("person-SmithPeter@example.com"); + assertThatAssetIsPersisted(result.returnedValue()); + + // ... a global admin can see the new domain setup as well if the domain OWNER role is assumed + context("superuser-alex@hostsharing.net", "hs_hosting_asset#example.net:OWNER"); // only works with the assumed role + assertThatAssetIsPersisted(result.returnedValue()); + } + + private void assertThatAssetIsPersisted(final HsHostingAssetRbacEntity saved) { + em.clear(); + attempt(em, () -> { + final var found = realAssetRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().map(HsHostingAsset::toString).contains(saved.toString()); + }); + } + } + + @Nested + class FindAssets { + + @ParameterizedTest + @EnumSource(TestCase.class) + public void globalAdmin_withoutAssumedRole_canViewArbitraryAssetsOfAllDebitors(final TestCase testCase) { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = repoUnderTest(testCase).findAllByCriteria(null, null, MANAGED_WEBSPACE); + + // then + exactlyTheseAssetsAreReturned( + result, + "HsHostingAsset(MANAGED_WEBSPACE, sec01, some Webspace, MANAGED_SERVER:vm1012, D-1000212:D-1000212 default project:separate ManagedWebspace)", + "HsHostingAsset(MANAGED_WEBSPACE, fir01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:D-1000111 default project:separate ManagedWebspace)", + "HsHostingAsset(MANAGED_WEBSPACE, thi01, some Webspace, MANAGED_SERVER:vm1013, D-1000313:D-1000313 default project:separate ManagedWebspace)"); + } + + @Test + public void normalUser_canViewOnlyRelatedAssets() { + // given: + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + final var projectUuid = projectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow().getUuid(); + + // when: + final var result = rbacAssetRepo.findAllByCriteria(projectUuid, null, null); + + // then: + exactlyTheseAssetsAreReturned( + result, + "HsHostingAsset(MANAGED_WEBSPACE, fir01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:D-1000111 default project:separate ManagedWebspace)", + "HsHostingAsset(MANAGED_SERVER, vm1011, some ManagedServer, D-1000111:D-1000111 default project:separate ManagedServer, { monit_max_cpu_usage : 90, monit_max_ram_usage : 80, monit_max_ssd_usage : 70 })"); + } + + @Test + public void managedServerAgent_canFindAssetsRelatedToManagedServer() { + // given + final var parentAssetUuid = realAssetRepo.findByIdentifier("vm1012").stream() + .filter(ha -> ha.getType() == MANAGED_SERVER) + .findAny().orElseThrow().getUuid(); + + // when + context("superuser-alex@hostsharing.net", "hs_hosting_asset#vm1012:AGENT"); + final var result = rbacAssetRepo.findAllByCriteria(null, parentAssetUuid, null); + + // then + exactlyTheseAssetsAreReturned( + result, + "HsHostingAsset(MANAGED_WEBSPACE, sec01, some Webspace, MANAGED_SERVER:vm1012, D-1000212:D-1000212 default project:separate ManagedWebspace)", + "HsHostingAsset(MARIADB_INSTANCE, vm1012.MariaDB.default, some default MariaDB instance, MANAGED_SERVER:vm1012)", + "HsHostingAsset(PGSQL_INSTANCE, vm1012.Postgresql.default, some default Postgresql instance, MANAGED_SERVER:vm1012)"); + } + + @Test + public void managedServerAgent_canFindRelatedEmailAddresses() { + // given + context("superuser-alex@hostsharing.net"); + + // when + context("superuser-alex@hostsharing.net", "hs_hosting_asset#sec01:AGENT"); + final var result = rbacAssetRepo.findAllByCriteria(null, null, EMAIL_ADDRESS); + + // then + exactlyTheseAssetsAreReturned( + result, + "HsHostingAsset(EMAIL_ADDRESS, test@sec.example.org, some E-Mail-Address, DOMAIN_MBOX_SETUP:sec.example.org|MBOX)"); + } + } + + @Nested + class UpdateAsset { + + @Test + public void hostsharingAdmin_canUpdateArbitraryServer() { + // given + final var givenAssetUuid = givenSomeTemporaryAsset("D-1000111 default project", "vm1000").getUuid(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var foundAsset = em.find(HsHostingAssetRbacEntity.class, givenAssetUuid); + foundAsset.getConfig().put("CPU", 2); + foundAsset.getConfig().remove("SSD-storage"); + foundAsset.getConfig().put("HSD-storage", 2048); + return toCleanup(rbacAssetRepo.save(foundAsset)); + }); + + // then + result.assertSuccessful(); + jpaAttempt.transacted(() -> { + assertThatAssetActuallyInDatabase(result.returnedValue()); + }).assertSuccessful(); + } + + private void assertThatAssetActuallyInDatabase(final HsHostingAssetRbacEntity saved) { + final var found = realAssetRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved) + .extracting(HsHostingAsset::getVersion).isEqualTo(saved.getVersion()); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyAsset() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + rbacAssetRepo.deleteByUuid(givenAsset.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + return realAssetRepo.findByUuid(givenAsset.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void relatedOwner_canDeleteTheirRelatedAsset() { + // given + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-FirbySusan@example.com", "hs_booking_project#D-1000111-D-1000111defaultproject:AGENT"); + assertThat(rbacAssetRepo.findByUuid(givenAsset.getUuid())).isPresent(); + + rbacAssetRepo.deleteByUuid(givenAsset.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + return realAssetRepo.findByUuid(givenAsset.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void relatedAdmin_canNotDeleteTheirRelatedAsset() { + // given + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-FirbySusan@example.com", "hs_hosting_asset#vm1000:ADMIN"); + assertThat(rbacAssetRepo.findByUuid(givenAsset.getUuid())).isPresent(); + + rbacAssetRepo.deleteByUuid(givenAsset.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " is not allowed to delete hs_hosting_asset"); + assertThat(jpaAttempt.transacted(() -> { + return realAssetRepo.findByUuid(givenAsset.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingAnAssetAlsoDeletesRelatedRolesAndGrants() { + // given + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return rbacAssetRepo.deleteByUuid(givenAsset.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + private HsHostingAssetRealEntity givenSomeTemporaryAsset(final String projectCaption, final String identifier) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); // needed to determine creator + final var givenBookingItem = givenBookingItem("D-1000111 default project", "test CloudServer"); + final var newAsset = HsHostingAssetRealEntity.builder() + .bookingItem(givenBookingItem) + .type(CLOUD_SERVER) + .identifier(identifier) + .caption(projectCaption) + .config(Map.ofEntries( + entry("CPU", 1), + entry("SSD-storage", 256))) + .build(); + + return toCleanup(realAssetRepo.save(newAsset)); + }).assertSuccessful().returnedValue(); + } + + HsBookingItemRealEntity givenBookingItem(final String projectCaption, final String bookingItemCaption) { + return realBookingItemRepo.findByCaption(bookingItemCaption).stream() + .filter(i -> i.getRelatedProject().getCaption().equals(projectCaption)) + .findAny().orElseThrow(); + } + + HsHostingAssetRealEntity givenHostingAsset(final String projectCaption, final HsHostingAssetType type) { + final var givenProject = projectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); + return realAssetRepo.findAllByCriteria(givenProject.getUuid(), null, type).stream() + .findAny().orElseThrow(); + } + + HsBookingItemRealEntity newBookingItem( + final HsBookingItemRealEntity parentBookingItem, + final HsBookingItemType type, + final String caption) { + final var newBookingItem = HsBookingItemRealEntity.builder() + .parentItem(parentBookingItem) + .type(type) + .caption(caption) + .build(); + return toCleanup(realBookingItemRepo.save(newBookingItem)); + } + + void exactlyTheseAssetsAreReturned( + final List actualResult, + final String... serverNames) { + assertThat(actualResult) + .extracting(HsHostingAsset::toString) + .extracting(input -> input.replaceAll("\\s+", " ")) + .extracting(input -> input.replaceAll("\"", "")) + .extracting(input -> input.replaceAll("\" : ", "\": ")) + .containsExactlyInAnyOrder(serverNames); + } + + void allTheseBookingProjectsAreReturned( + final List actualResult, + final String... serverNames) { + assertThat(actualResult) + .extracting(HsHostingAsset::toString) + .extracting(input -> input.replaceAll("\\s+", " ")) + .extracting(input -> input.replaceAll("\"", "")) + .extracting(input -> input.replaceAll("\" : ", "\": ")) + .contains(serverNames); + } + + private HsHostingAssetRepository repoUnderTest(final HsHostingAssetRepositoryIntegrationTest.TestCase testCase) { + return testCase.repo(HsHostingAssetRepositoryIntegrationTest.this); + } + + enum TestCase { + REAL { + @Override + HsHostingAssetRepository repo(final HsHostingAssetRepositoryIntegrationTest test) { + return test.realAssetRepo; + } + }, + RBAC { + @Override + HsHostingAssetRepository repo(final HsHostingAssetRepositoryIntegrationTest test) { + return test.rbacAssetRepo; + } + }; + + abstract HsHostingAssetRepository repo(final HsHostingAssetRepositoryIntegrationTest test); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTestEntities.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTestEntities.java new file mode 100644 index 00000000..33a22b1b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTestEntities.java @@ -0,0 +1,36 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY; + +public class HsHostingAssetTestEntities { + + public static final HsHostingAssetRbacEntity MANAGED_SERVER_HOSTING_ASSET_RBAC_TEST_ENTITY = HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed server") + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .build(); + + public static final HsHostingAssetRealEntity MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed server") + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .build(); + + public static final HsHostingAssetRbacEntity MANAGED_WEBSPACE_HOSTING_ASSET_RBAC_TEST_ENTITY = HsHostingAssetRbacEntity.builder() + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("xyz00") + .caption("some managed webspace") + .bookingItem(MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY) + .build(); + + public static final HsHostingAssetRealEntity MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("xyz00") + .caption("some managed webspace") + .bookingItem(MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY) + .build(); + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java new file mode 100644 index 00000000..cc700850 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java @@ -0,0 +1,234 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsHostingAssetTypeUnitTest { + + @Test + void generatedPlantUML() { + final var result = HsHostingAssetType.renderAsEmbeddedPlantUml(); + + assertThat(result).isEqualTo(""" + ## HostingAsset Type Structure + + + ### Server+Webspace + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP + } + + package Hosting #feb28c{ + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IPV4_NUMBER + entity HA_IPV6_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_CLOUD_SERVER *==> BI_CLOUD_SERVER + HA_MANAGED_SERVER *==> BI_MANAGED_SERVER + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_IPV4_NUMBER o..> HA_CLOUD_SERVER + HA_IPV4_NUMBER o..> HA_MANAGED_SERVER + HA_IPV4_NUMBER o..> HA_MANAGED_WEBSPACE + HA_IPV6_NUMBER o..> HA_CLOUD_SERVER + HA_IPV6_NUMBER o..> HA_MANAGED_SERVER + HA_IPV6_NUMBER o..> HA_MANAGED_WEBSPACE + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + ### Domain + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP + } + + package Hosting #feb28c{ + package Domain #99bcdb { + entity HA_DOMAIN_SETUP + entity HA_DOMAIN_DNS_SETUP + entity HA_DOMAIN_HTTP_SETUP + entity HA_DOMAIN_SMTP_SETUP + entity HA_DOMAIN_MBOX_SETUP + entity HA_EMAIL_ADDRESS + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_DOMAIN_SETUP *..> BI_DOMAIN_SETUP + HA_DOMAIN_SETUP o..> HA_DOMAIN_SETUP + HA_DOMAIN_DNS_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_DNS_SETUP o--> HA_MANAGED_WEBSPACE + HA_DOMAIN_HTTP_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_HTTP_SETUP o--> HA_UNIX_USER + HA_DOMAIN_SMTP_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_SMTP_SETUP o--> HA_MANAGED_WEBSPACE + HA_DOMAIN_MBOX_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_MBOX_SETUP o--> HA_MANAGED_WEBSPACE + HA_EMAIL_ADDRESS *==> HA_DOMAIN_MBOX_SETUP + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + ### MariaDB + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP + } + + package Hosting #feb28c{ + package MariaDB #99bcdb { + entity HA_MARIADB_INSTANCE + entity HA_MARIADB_USER + entity HA_MARIADB_DATABASE + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_MARIADB_USER *==> HA_MANAGED_WEBSPACE + HA_MARIADB_USER o--> HA_MARIADB_INSTANCE + HA_MARIADB_DATABASE *==> HA_MARIADB_USER + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + ### PostgreSQL + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_SETUP + } + + package Hosting #feb28c{ + package PostgreSQL #99bcdb { + entity HA_PGSQL_INSTANCE + entity HA_PGSQL_USER + entity HA_PGSQL_DATABASE + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_PGSQL_USER *==> HA_MANAGED_WEBSPACE + HA_PGSQL_USER o--> HA_PGSQL_INSTANCE + HA_PGSQL_DATABASE *==> HA_PGSQL_USER + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + This code generated was by HsHostingAssetType.main, do not amend manually. + """); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java new file mode 100644 index 00000000..7a60d16c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DnsUnitTest { + + @Test + void isRegistrarLevelDomain() { + assertThat(Dns.isRegistrarLevelDomain("de")).isTrue(); + assertThat(Dns.isRegistrarLevelDomain("example.de")).isFalse(); + + assertThat(Dns.isRegistrarLevelDomain("co.uk")).isTrue(); + assertThat(Dns.isRegistrarLevelDomain("example.co.uk")).isFalse(); + assertThat(Dns.isRegistrarLevelDomain("co.uk.com")).isFalse(); + } + + @Test + void isRegistrableDomain() { + assertThat(Dns.isRegistrableDomain("de")).isFalse(); + assertThat(Dns.isRegistrableDomain("example.de")).isTrue(); + assertThat(Dns.isRegistrableDomain("sub.example.de")).isFalse(); + + assertThat(Dns.isRegistrableDomain("co.uk")).isFalse(); + assertThat(Dns.isRegistrableDomain("example.co.uk")).isTrue(); + assertThat(Dns.isRegistrableDomain("sub.example.co.uk")).isFalse(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java new file mode 100644 index 00000000..f6f9a510 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java @@ -0,0 +1,53 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HostingAssetEntityValidatorRegistryUnitTest { + + @Test + void forTypeWithUnknownTypeThrowsException() { + // when + final var thrown = catchThrowable(() -> { + HostingAssetEntityValidatorRegistry.forType(null); + }); + + // then + assertThat(thrown).hasMessage("no validator found for type null"); + } + + @Test + void typesReturnsAllImplementedTypes() { + // when + final var types = HostingAssetEntityValidatorRegistry.types(); + + // then + // TODO.test: when all types are implemented, replace with set of all types: + // assertThat(types).isEqualTo(EnumSet.allOf(HsHostingAssetType.class)); + // also remove "Implemented" from the test method name. + assertThat(types).containsExactlyInAnyOrder( + HsHostingAssetType.CLOUD_SERVER, + HsHostingAssetType.MANAGED_SERVER, + HsHostingAssetType.MANAGED_WEBSPACE, + HsHostingAssetType.UNIX_USER, + HsHostingAssetType.EMAIL_ALIAS, + HsHostingAssetType.DOMAIN_SETUP, + HsHostingAssetType.DOMAIN_DNS_SETUP, + HsHostingAssetType.DOMAIN_HTTP_SETUP, + HsHostingAssetType.DOMAIN_SMTP_SETUP, + HsHostingAssetType.DOMAIN_MBOX_SETUP, + HsHostingAssetType.EMAIL_ADDRESS, + HsHostingAssetType.MARIADB_INSTANCE, + HsHostingAssetType.MARIADB_USER, + HsHostingAssetType.MARIADB_DATABASE, + HsHostingAssetType.PGSQL_INSTANCE, + HsHostingAssetType.PGSQL_USER, + HsHostingAssetType.PGSQL_DATABASE, + HsHostingAssetType.IPV4_NUMBER, + HsHostingAssetType.IPV6_NUMBER + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..669a0c46 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java @@ -0,0 +1,107 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsCloudServerHostingAssetValidatorUnitTest { + + @Test + void validatesProperties() { + // given + final var cloudServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(CLOUD_SERVER) + .identifier("vm1234") + .config(Map.ofEntries( + entry("RAM", 2000) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(cloudServerHostingAssetEntity.getType()); + + + // when + final var result = validator.validateEntity(cloudServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'CLOUD_SERVER:vm1234.bookingItem' must be of type CLOUD_SERVER but is null", + "'CLOUD_SERVER:vm1234.config.RAM' is not expected but is set to '2000'"); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var cloudServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(CLOUD_SERVER) + .identifier("xyz99") + .bookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(cloudServerHostingAssetEntity.getType()); + + + // when + final var result = validator.validateEntity(cloudServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^vm[0-9][0-9][0-9][0-9]$', but is 'xyz99'"); + } + + @Test + void containsAllValidations() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(CLOUD_SERVER); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void validatesBookingItemType() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MANAGED_SERVER:xyz00.bookingItem' must be of type MANAGED_SERVER but is of type CLOUD_SERVER"); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(CLOUD_SERVER) + .identifier("vm1234") + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_SERVER).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'CLOUD_SERVER:vm1234.parentAsset' must be null but is of type MANAGED_SERVER", + "'CLOUD_SERVER:vm1234.assignedToAsset' must be null but is of type CLOUD_SERVER"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..41684c3b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java @@ -0,0 +1,357 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetTestEntities.MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_COMMENT; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_RECORD_DATA; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_RECORD_TYPE; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_IN; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_NAME; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_TTL; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainDnsSetupHostingAssetValidatorUnitTest { + + static final HsHostingAssetRealEntity validDomainSetupEntity = HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.org") + .build(); + + private EntityManager em; + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(DOMAIN_DNS_SETUP) + .parentAsset(validDomainSetupEntity) + .assignedToAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("example.org|DNS") + .config(Map.ofEntries( + entry("TTL", 21600), + entry("auto-SOA", true), + entry("auto-NS-RR", true), + entry("auto-MX-RR", true), + entry("auto-A-RR", true), + entry("auto-AAAA-RR", true), + entry("auto-MAILSERVICES-RR", true), + entry("auto-AUTOCONFIG-RR", true), + entry("auto-AUTODISCOVER-RR", true), + entry("auto-DKIM-RR", true), + entry("auto-SPF-RR", true), + entry("auto-WILDCARD-MX-RR", true), + entry("auto-WILDCARD-A-RR", true), + entry("auto-WILDCARD-AAAA-RR", true), + entry("auto-WILDCARD-SPF-RR", true), + entry("user-RR", Array.of( + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com", + "_acme-challenge.PAULCHEN-VS.core.example.org. 60 IN CNAME _acme-challenge.core.example.org.acme-pki.de.") + ) + )); + } + + @BeforeEach + void reset() { + HsDomainDnsSetupHostingAssetValidator.addZonefileErrorsTo(null); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_DNS_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=integer, propertyName=TTL, min=0, defaultValue=21600}", + "{type=boolean, propertyName=auto-SOA, defaultValue=true}", + "{type=boolean, propertyName=auto-NS-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-MX-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-A-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AAAA-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-MAILSERVICES-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AUTOCONFIG-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AUTODISCOVER-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-DKIM-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-SPF-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-MX-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-A-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-AAAA-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-SPF-RR, defaultValue=true}", + "{type=string[], propertyName=user-RR, elementsOf={type=string, propertyName=user-RR, matchesRegEx=[(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[iI][nN][ \t]+[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?, (\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+[iI][nN][ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?], required=true}}" + ); + } + + @Test + void preprocessesTakesIdentifierFromParent() { + // given + final var givenEntity = validEntityBuilder().build(); + assertThat(givenEntity.getParentAsset().getIdentifier()).as("preconditon failed").isEqualTo("example.org"); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + validator.preprocessEntity(givenEntity); + + // then + assertThat(givenEntity.getIdentifier()).isEqualTo("example.org|DNS"); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier("example.org").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^\\Qexample.org|DNS\\E$', but is 'example.org'" + ); + } + + @Test + void acceptsValidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier(validDomainSetupEntity.getIdentifier()+"|DNS").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(null) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_DNS_SETUP:example.org|DNS.bookingItem' must be null but is of type CLOUD_SERVER", + "'DOMAIN_DNS_SETUP:example.org|DNS.parentAsset' must be of type DOMAIN_SETUP but is null", + "'DOMAIN_DNS_SETUP:example.org|DNS.assignedToAsset' must be of type MANAGED_WEBSPACE but is of type DOMAIN_SETUP"); + } + + @Test + void acceptsValidEntityItself() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateEntity(givenEntity); + + // then + assertThat(errors).isEmpty(); + } + + @Test + void acceptsValidEntityInContext() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateContext(givenEntity); + + // then + assertThat(errors).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("TTL", "1d30m"), // currently only an integer for seconds is implemented here + entry("user-RR", Array.of( + "@ 1814400 IN 1814400 BAD1 TTL only allowed once", + "www BAD1 Record-Class missing / not enough columns")) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_DNS_SETUP:example.org|DNS.config.TTL' is expected to be of type Integer, but is of type String", + "'DOMAIN_DNS_SETUP:example.org|DNS.config.user-RR' is expected to match any of [(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[iI][nN][ \t]+[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?, (\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+[iI][nN][ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?] but '@ 1814400 IN 1814400 BAD1 TTL only allowed once' does not match any", + "'DOMAIN_DNS_SETUP:example.org|DNS.config.user-RR' is expected to match any of [(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[iI][nN][ \t]+[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?, (\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+[iI][nN][ \t]+(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?[a-zA-Z]+[ \t]+(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*(;.*)?] but 'www BAD1 Record-Class missing / not enough columns' does not match any"); + } + + @Test + void validNameMatchesRegEx() { + assertThat("@ ").matches(RR_REGEX_NAME); + assertThat("ns ").matches(RR_REGEX_NAME); + assertThat("example.com. ").matches(RR_REGEX_NAME); + assertThat("example.ORG. ").matches(RR_REGEX_NAME); + } + + @Test + void validTtlMatchesRegEx() { + assertThat("12400 ").matches(RR_REGEX_TTL); + assertThat("12400\t\t ").matches(RR_REGEX_TTL); + assertThat("12400 \t\t").matches(RR_REGEX_TTL); + assertThat("1h30m ").matches(RR_REGEX_TTL); + assertThat("30m ").matches(RR_REGEX_TTL); + } + + @Test + void validInMatchesRegEx() { + assertThat("in ").matches(RR_REGEX_IN); + assertThat("IN ").matches(RR_REGEX_IN); + assertThat("IN\t\t ").matches(RR_REGEX_IN); + assertThat("IN \t\t").matches(RR_REGEX_IN); + } + + @Test + void validRecordTypeMatchesRegEx() { + assertThat("a ").matches(RR_RECORD_TYPE); + assertThat("CNAME ").matches(RR_RECORD_TYPE); + assertThat("CNAME\t\t ").matches(RR_RECORD_TYPE); + assertThat("CNAME \t\t").matches(RR_RECORD_TYPE); + } + + @Test + void validRecordDataMatchesRegEx() { + assertThat("example.com.").matches(RR_RECORD_DATA); + assertThat("example.com. ").matches(RR_RECORD_DATA); + assertThat("123.123.123.123").matches(RR_RECORD_DATA); + assertThat("123.123.123.123 ").matches(RR_RECORD_DATA); + assertThat("_acme-challenge.core.example.org.acme-pki.de.").matches(RR_RECORD_DATA); + assertThat("(some more complex argument in parenthesis)").matches(RR_RECORD_DATA); + assertThat("\"some more complex argument; including a semicolon\"").matches(RR_RECORD_DATA); + } + + @Test + void validCommentMatchesRegEx() { + assertThat("; whatever ; \" really anything").matches(RR_COMMENT); + } + + @Test + void generatesZonefile() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = (HsDomainDnsSetupHostingAssetValidator) HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var zonefile = validator.toZonefileString(givenEntity); + + // then + assertThat(zonefile).isEqualTo(""" + $TTL 21600 + + example.org. IN SOA h00.hostsharing.net. hostmaster.hostsharing.net. ( + 1303649373 ; serial secs since Jan 1 1970 + 6H ; refresh (>=10000) + 1H ; retry (>=1800) + 1W ; expire + 1H ; minimum + ) + + example.org. IN NS dns1.hostsharing.net. + example.org. IN NS dns2.hostsharing.net. + example.org. IN NS dns3.hostsharing.net. + + example.org. IN MX 30 mailin1.hostsharing.net. + example.org. IN MX 30 mailin2.hostsharing.net. + example.org. IN MX 30 mailin3.hostsharing.net. + + example.org. IN A 83.223.95.160 + example.org. IN AAAA 2a01:37:1000::53df:5fa0:0 + default._domainkey 21600 IN TXT "v=DKIM1; h=sha256; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmdM9d15bqe94zbHVcKKpUF875XoCWHKRap/sG3NJZ9xZ/BjfGXmqoEYeFNpX3CB7pOXhH5naq4N+6gTjArTviAiVThHXyebhrxaf1dVS4IUC6raTEyQrWPZUf7ZxXmcCYvOdV4jIQ8GRfxwxqibIJcmMiufXTLIgRUif5uaTgFwIDAQAB" + example.org. IN TXT "v=spf1 include:spf.hostsharing.net ?all" + + *.example.org. IN MX 30 mailin1.hostsharing.net. + *.example.org. IN MX 30 mailin1.hostsharing.net. + *.example.org. IN MX 30 mailin1.hostsharing.net. + + *.example.org. IN A 83.223.95.160 + *.example.org. IN AAAA 2a01:37:1000::53df:5fa0:0 + *.example.org. IN TXT "v=spf1 include:spf.hostsharing.net ?all" + + www IN CNAME example.com. ; www.example.com is an alias for example.com + test1 IN 1h30m CNAME example.com. + test2 1h30m IN CNAME example.com. + ns IN A 192.0.2.2; IPv4 address for ns.example.com + _acme-challenge.PAULCHEN-VS.core.example.org. 60 IN CNAME _acme-challenge.core.example.org.acme-pki.de. + """); + } + + @Test + void rejectsInvalidZonefile() { + // given + final var givenEntity = validEntityBuilder().config(Map.ofEntries( + entry("user-RR", Array.of( + "example.org. 1814400 IN SOA example.org. root.example.org (1234 10800 900 604800 86400)", + "example.org. 1814400 IN SOA example.org. root.example.org (4321 10800 900 604800 86400)" + )) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateContext(givenEntity); + + // then + assertThat(errors).containsExactlyInAnyOrder( + "[example.org|DNS] dns_master_load:line 26: example.org: multiple RRs of singleton type", + "[example.org|DNS] zone example.org/IN: loading from master file (null) failed: multiple RRs of singleton type", + "[example.org|DNS] zone example.org/IN: not loaded due to errors." + ); + } + + @Test + void acceptsInvalidZonefileWithActiveErrorFilter() { + // given + final var givenEntity = validEntityBuilder().config(Map.ofEntries( + entry("user-RR", Array.of( + "example.org. 1814400 IN SOA example.org. root.example.org (1234 10800 900 604800 86400)", + "example.org. 1814400 IN SOA example.org. root.example.org (4321 10800 900 604800 86400)" + )) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var zonefileErrors = new ArrayList(); + HsDomainDnsSetupHostingAssetValidator.addZonefileErrorsTo(zonefileErrors); + final var errors = validator.validateContext(givenEntity); + + // then + assertThat(errors).isEmpty(); + assertThat(zonefileErrors).containsExactlyInAnyOrder( + "[example.org|DNS] dns_master_load:line 26: example.org: multiple RRs of singleton type", + "[example.org|DNS] zone example.org/IN: loading from master file (null) failed: multiple RRs of singleton type", + "[example.org|DNS] zone example.org/IN: not loaded due to errors." + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..91fecdd5 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java @@ -0,0 +1,164 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.mapper.Array; +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_HTTP_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainHttpSetupHostingAssetValidatorUnitTest { + + static final HsHostingAssetRealEntity validDomainSetupEntity = HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.org") + .build(); + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(DOMAIN_HTTP_SETUP) + .parentAsset(validDomainSetupEntity) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(UNIX_USER).build()) + .identifier("example.org|HTTP") + .config(Map.ofEntries( + entry("passenger-errorpage", true), + entry("fcgi-php-bin", "/usr/bin/whatsoever"), + entry("subdomains", Array.of("www", "test") + ) + )); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_HTTP_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=boolean, propertyName=htdocsfallback, defaultValue=true}", + "{type=boolean, propertyName=indexes, defaultValue=true}", + "{type=boolean, propertyName=cgi, defaultValue=true}", + "{type=boolean, propertyName=passenger, defaultValue=true}", + "{type=boolean, propertyName=passenger-errorpage}", + "{type=boolean, propertyName=fastcgi, defaultValue=true}", + "{type=boolean, propertyName=autoconfig, defaultValue=true}", + "{type=boolean, propertyName=greylisting, defaultValue=true}", + "{type=boolean, propertyName=includes, defaultValue=true}", + "{type=boolean, propertyName=letsencrypt, defaultValue=true}", + "{type=boolean, propertyName=multiviews, defaultValue=true}", + "{type=string, propertyName=fcgi-php-bin, matchesRegEx=[^/.*], provided=[/usr/lib/cgi-bin/php], defaultValue=/usr/lib/cgi-bin/php}", + "{type=string, propertyName=passenger-nodejs, matchesRegEx=[^/.*], provided=[/usr/bin/node], defaultValue=/usr/bin/node}", + "{type=string, propertyName=passenger-python, matchesRegEx=[^/.*], provided=[/usr/bin/python3], defaultValue=/usr/bin/python3}", + "{type=string, propertyName=passenger-ruby, matchesRegEx=[^/.*], provided=[/usr/bin/ruby], defaultValue=/usr/bin/ruby}", + "{type=string[], propertyName=subdomains, elementsOf={type=string, propertyName=subdomains, matchesRegEx=[(\\*|(?!-)[A-Za-z0-9-]{1,63}(? validEntityBuilder( + final String domainName, + final Function, HsBookingItemRealEntity> buildBookingItem) { + final var project = HsBookingProjectRealEntity.builder().build(); + final var bookingItem = buildBookingItem.apply( + HsBookingItemRealEntity.builder() + .project(project) + .type(HsBookingItemType.DOMAIN_SETUP) + .resources(new HashMap<>(ofEntries( + entry("domainName", domainName) + )))); + HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP).prepareProperties(null, bookingItem); + return HsHostingAssetRbacEntity.builder() + .type(DOMAIN_SETUP) + .bookingItem(bookingItem) + .identifier(domainName); + } + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder(final String domainName) { + return validEntityBuilder(domainName, HsBookingItemRealEntity.HsBookingItemRealEntityBuilder::build); + } + + @AfterEach + void cleanup() { + Dns.resetFakeResults(); + } + + //===================================================================================================================== + + enum InvalidSubDomainNameIdentifierForExampleOrg { + IDENTICAL("example.org"), + TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.example.org"), + DASH_AT_BEGINNING("-sub.example.org"), + DOT(".example.org"), + DOT_AT_BEGINNING(".sub.example.org"), + DOUBLE_DOT("sub..example.com."); + + final String domainName; + + InvalidSubDomainNameIdentifierForExampleOrg(final String domainName) { + this.domainName = domainName; + } + } + + @ParameterizedTest + @EnumSource(InvalidSubDomainNameIdentifierForExampleOrg.class) + void rejectsInvalidIdentifier(final InvalidSubDomainNameIdentifierForExampleOrg testCase) { + // given + final var givenEntity = validEntityBuilder(testCase.domainName) + .bookingItem(null) + .parentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier("example.org").build()) + .build(); + // fakeValidDnsVerification(givenEntity); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).contains( + "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(? bib.type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).contains( + "'DOMAIN_SETUP:example.org.bookingItem' or parentItem must be null but is of type CLOUD_SERVER", + "'DOMAIN_SETUP:example.org.parentAsset' must be null or of type DOMAIN_SETUP but is of type CLOUD_SERVER", + "'DOMAIN_SETUP:example.org.assignedToAsset' must be null but is of type MANAGED_SERVER"); + } + + @Test + void rejectsDomainNameNotMatchingBookingItemDomainName() { + // given + final var domainSetupHostingAssetEntity = validEntityBuilder( + "not-matching-booking-item-domain-name.org", + bib -> bib.resources(new HashMap<>(ofEntries( + entry("domainName", "example.org") + ))).build() + ).build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match 'example.org', but is 'not-matching-booking-item-domain-name.org'"); + } + + @ParameterizedTest + @ValueSource(strings = { "not-matching-booking-item-domain-name.org", "indirect.subdomain.example.org" }) + void rejectsDomainNameWhichIsNotADirectSubdomainOfParentAsset(final String newDomainName) { + // given + final var domainSetupHostingAssetEntity = validEntityBuilder(newDomainName) + .bookingItem(null) + .parentAsset(createValidParentDomainSetupAsset("example.org")) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(? validate() { + if ( domainAsset.getBookingItem() != null ) { + final var biValidation = HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP) + .validateEntity(domainAsset.getBookingItem()); + if (!biValidation.isEmpty()) { + return biValidation; + } + } + + return HostingAssetEntityValidatorRegistry.forType(DOMAIN_SETUP) + .validateEntity(domainAsset); + } + + DomainSetupBuilder withParentAsset(final String parentAssetDomainName) { + domainAsset.setBookingItem(null); + domainAsset.setParentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(parentAssetDomainName).build()); + return this; + } + } + + private DomainSetupBuilder domainSetupFor(final String domainName) { + return new DomainSetupBuilder(domainName); + } + + private DomainSetupBuilder domainSetupWithParentAssetFor(final String domainName) { + return new DomainSetupBuilder( + HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .identifier(Dns.superDomain(domainName).orElseThrow()) + .build(), + domainName); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..e8242260 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidatorUnitTest.java @@ -0,0 +1,134 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +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.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainSmtpSetupHostingAssetValidatorUnitTest { + + static final HsHostingAssetRealEntity validDomainSetupEntity = HsHostingAssetRealEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.org") + .build(); + + static HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(DOMAIN_SMTP_SETUP) + .parentAsset(validDomainSetupEntity) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .identifier("example.org|SMTP"); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SMTP_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void preprocessesTakesIdentifierFromParent() { + // given + final var givenEntity = validEntityBuilder().build(); + assertThat(givenEntity.getParentAsset().getIdentifier()).as("precondition failed").isEqualTo("example.org"); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + validator.preprocessEntity(givenEntity); + + // then + assertThat(givenEntity.getIdentifier()).isEqualTo("example.org|SMTP"); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier("example.org").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^\\Qexample.org|SMTP\\E$', but is 'example.org'" + ); + } + + @Test + void acceptsValidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier(validDomainSetupEntity.getIdentifier()+"|SMTP").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .assignedToAsset(null) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_SMTP_SETUP:example.org|SMTP.bookingItem' must be null but is of type CLOUD_SERVER", + "'DOMAIN_SMTP_SETUP:example.org|SMTP.parentAsset' must be of type DOMAIN_SETUP but is of type MANAGED_WEBSPACE", + "'DOMAIN_SMTP_SETUP:example.org|SMTP.assignedToAsset' must be of type MANAGED_WEBSPACE but is null"); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateEntity(givenEntity); + + // then + assertThat(errors).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("any", "false") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_SMTP_SETUP:example.org|SMTP.config.any' is not expected but is set to 'false'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..88adb55b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java @@ -0,0 +1,192 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_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.HsHostingAssetType.DOMAIN_MBOX_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; +import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class HsEMailAddressHostingAssetValidatorUnitTest { + + final static HsHostingAssetRealEntity domainSetup = HsHostingAssetRealEntity.builder() + .type(DOMAIN_MBOX_SETUP) + .identifier("example.org") + .build(); + final static HsHostingAssetRealEntity domainMboxSetup = HsHostingAssetRealEntity.builder() + .type(DOMAIN_MBOX_SETUP) + .identifier("example.org|MBOX") + .parentAsset(domainSetup) + .build(); + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(EMAIL_ADDRESS) + .parentAsset(domainMboxSetup) + .identifier("old-local-part@example.org") + .config(new HashMap<>(ofEntries( + entry("local-part", "old-local-part"), + entry("target", Array.of( + "xyz00", + "xyz00-abc", + "xyz00-xyz+list", + "office@example.com", + "/dev/null" + )) + ))); + } + + @Test + void containsAllValidations() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(EMAIL_ADDRESS); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=string, propertyName=local-part, matchesRegEx=[^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$], writeOnce=true}", + "{type=string, propertyName=sub-domain, matchesRegEx=[^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$], writeOnce=true}", + "{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 acceptsValidEntity() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .config(new HashMap<>(ofEntries( + entry("local-part", "no@allowed"), + entry("sub-domain", "no@allowedeither"), + entry("target", Array.of( + "xyz00", + "xyz00-abc", + "garbage", + "office@example.com"))))) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match", + "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match", + "'EMAIL_ADDRESS:old-local-part@example.org.config.target' is expected to match any of [^[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$] but 'garbage' does not match any"); + } + + @Test + void rejectsOverwritingWriteOnceProperties() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .isLoaded(true) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + emailAddressHostingAssetEntity.getConfig().put("local-part", "new-local-part"); + emailAddressHostingAssetEntity.getConfig().put("sub-domain", "new-sub-domain"); + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is write-once but given as 'new-local-part'", + "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is write-once but given as 'new-sub-domain'"); + } + + @Test + void rejectsRemovingWriteOnceProperties() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .config(new HashMap<>(ofEntries( + entry("local-part", "old-local-part"), + entry("sub-domain", "old-sub-domain"), + entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) + ))) + .isLoaded(true) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + emailAddressHostingAssetEntity.getConfig().remove("local-part"); + emailAddressHostingAssetEntity.getConfig().remove("sub-domain"); + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is write-once but got removed", + "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is write-once but got removed"); + } + + @Test + void acceptsOverwritingWriteOncePropertiesWithSameValues() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .isLoaded(true) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + emailAddressHostingAssetEntity.getConfig().put("local-part", "old-local-part"); + emailAddressHostingAssetEntity.getConfig().remove("sub-domain"); // is not there anyway + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .identifier("abc00-office") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^\\Qold-local-part@example.org\\E$', but is 'abc00-office'"); + } + + @Test + void validatesInvalidReferences() { + // given + final var emailAddressHostingAssetEntity = validEntityBuilder() + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAddressHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAddressHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ADDRESS:old-local-part@example.org.bookingItem' must be null but is of type MANAGED_SERVER", + "'EMAIL_ADDRESS:old-local-part@example.org.parentAsset' must be of type DOMAIN_MBOX_SETUP but is of type MANAGED_SERVER", + "'EMAIL_ADDRESS:old-local-part@example.org.assignedToAsset' must be null but is of type MANAGED_SERVER"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..7d43b129 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidatorUnitTest.java @@ -0,0 +1,149 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_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.HsHostingAssetTestEntities.MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ALIAS; +import static org.assertj.core.api.Assertions.assertThat; + +class HsEMailAliasHostingAssetValidatorUnitTest { + + @Test + void containsAllValidations() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(EMAIL_ALIAS); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{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.-]+$, ^:include:/.*$, ^\\|.*$, ^/dev/null$], maxLength=320}, required=true, minLength=1}"); + } + + @Test + void acceptsValidEntity() { + // given + final var emailAliasHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(EMAIL_ALIAS) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .config(Map.ofEntries( + entry("target", Array.of( + "xyz00", + "xyz00-abc", + "office@example.com", + "/dev/null", + "|/home/pacs/xyz00/mailinglists/ecartis -s xyz00-intern" + )) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAliasHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAliasHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidConfig() { + // given + final var emailAliasHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(EMAIL_ALIAS) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .config(Map.ofEntries( + entry("target", Array.of( + "/dev/null", + "xyz00", + "xyz00-abc", + "garbage", + "office@example.com", + ":include:/home/pacs/xyz00/mailinglists/textfile", + "|/home/pacs/xyz00/mailinglists/executable" + )) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAliasHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAliasHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ALIAS:xyz00-office.config.target' is expected to match any of [^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$, ^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$, ^:include:/.*$, ^\\|.*$, ^/dev/null$] but 'garbage' does not match any"); + } + + @Test + void rejectsEmptyTargetArray() { + // given + final var emailAliasHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(EMAIL_ALIAS) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("xyz00-office") + .config(Map.ofEntries( + entry("target", new String[0]) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAliasHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAliasHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ALIAS:xyz00-office.config.target' length is expected to be at min 1 but length of [[]] is 0"); + } + + @Test + void rejectsInvalidIndentifier() { + // given + final var emailAliasHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(EMAIL_ALIAS) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("abc00-office") + .config(Map.ofEntries( + entry("target", Array.of("office@example.com")) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAliasHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAliasHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^xyz00$|^xyz00-[a-z0-9][a-z0-9\\._-]*$', but is 'abc00-office'"); + } + + @Test + void validatesInvalidReferences() { + // given + final var emailAliasHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(EMAIL_ALIAS) + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("abc00-office") + .config(Map.ofEntries( + entry("target", Array.of("office@example.com")) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(emailAliasHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(emailAliasHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'EMAIL_ALIAS:abc00-office.bookingItem' must be null but is of type MANAGED_SERVER", + "'EMAIL_ALIAS:abc00-office.parentAsset' must be of type MANAGED_WEBSPACE but is of type MANAGED_SERVER", + "'EMAIL_ALIAS:abc00-office.assignedToAsset' must be null but is of type MANAGED_SERVER"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..ea10190a --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidatorUnitTest.java @@ -0,0 +1,120 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV4_NUMBER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsIPv4NumberHostingAssetValidatorUnitTest { + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(IPV4_NUMBER) + .identifier("83.223.95.145"); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(IPV4_NUMBER); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"a.b.c.d", "83.223.95", "83.223.95.145.1", "2a01:37:1000::53df:5f91:0"}) + void rejectsInvalidIdentifier(final String givenIdentifier) { + // given + final var givenEntity = validEntityBuilder().identifier(givenIdentifier).build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$', but is '" + givenIdentifier + "'" + ); + } + + @ParameterizedTest + @EnumSource(value = HsHostingAssetType.class, names = { "CLOUD_SERVER", "MANAGED_SERVER", "MANAGED_WEBSPACE" }) + void acceptsValidReferencedEntity(final HsHostingAssetType givenAssignedToAssetType) { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .assignedToAsset(HsHostingAssetRealEntity.builder().type(givenAssignedToAssetType).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(UNIX_USER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'IPV4_NUMBER:83.223.95.145.bookingItem' must be null but is of type CLOUD_SERVER", + "'IPV4_NUMBER:83.223.95.145.parentAsset' must be null but is of type MANAGED_WEBSPACE", + "'IPV4_NUMBER:83.223.95.145.assignedToAsset' must be null or of type CLOUD_SERVER or MANAGED_SERVER or MANAGED_WEBSPACE but is of type UNIX_USER"); + } + + @Test + void rejectsInvalidProperties() { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("any", "false") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'IPV4_NUMBER:83.223.95.145.config.any' is not expected but is set to 'false'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..ce7fae6c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidatorUnitTest.java @@ -0,0 +1,120 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV6_NUMBER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsIPv6NumberHostingAssetValidatorUnitTest { + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(IPV6_NUMBER) + .identifier("2001:db8:3333:4444:5555:6666:7777:8888"); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(IPV6_NUMBER); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"83.223.95", "2a01:37:1000::53df:5f91:0:123::123"}) + void rejectsInvalidIdentifier(final String givenIdentifier) { + // given + final var givenEntity = validEntityBuilder().identifier(givenIdentifier).build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).contains( + "'identifier' expected to be a valid IPv6 address, but is '" + givenIdentifier + "'" + ); + } + + @ParameterizedTest + @EnumSource(value = HsHostingAssetType.class, names = { "CLOUD_SERVER", "MANAGED_SERVER", "MANAGED_WEBSPACE" }) + void acceptsValidReferencedEntity(final HsHostingAssetType givenAssignedToAssetType) { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .assignedToAsset(HsHostingAssetRealEntity.builder().type(givenAssignedToAssetType).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(UNIX_USER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'IPV6_NUMBER:2001:db8:3333:4444:5555:6666:7777:8888.bookingItem' must be null but is of type CLOUD_SERVER", + "'IPV6_NUMBER:2001:db8:3333:4444:5555:6666:7777:8888.parentAsset' must be null but is of type MANAGED_WEBSPACE", + "'IPV6_NUMBER:2001:db8:3333:4444:5555:6666:7777:8888.assignedToAsset' must be null or of type CLOUD_SERVER or MANAGED_SERVER or MANAGED_WEBSPACE but is of type UNIX_USER"); + } + + @Test + void rejectsInvalidProperties() { + // given + final var ipNumberHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("any", "false") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(ipNumberHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(ipNumberHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'IPV6_NUMBER:2001:db8:3333:4444:5555:6666:7777:8888.config.any' is not expected but is set to 'false'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..d657f91c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java @@ -0,0 +1,88 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsManagedServerHostingAssetValidatorUnitTest { + + @Test + void validatesProperties() { + // given + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_SERVER) + .identifier("vm1234") + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .config(Map.ofEntries( + entry("monit_max_hdd_usage", "90"), + entry("monit_max_cpu_usage", 2), + entry("monit_max_ram_usage", 101) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedWebspaceHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MANAGED_SERVER:vm1234.parentAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:vm1234.assignedToAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:vm1234.config.monit_max_cpu_usage' is expected to be at least 10 but is 2", + "'MANAGED_SERVER:vm1234.config.monit_max_ram_usage' is expected to be at most 100 but is 101", + "'MANAGED_SERVER:vm1234.config.monit_max_hdd_usage' is expected to be of type Integer, but is of type String"); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.MANAGED_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^vm[0-9][0-9][0-9][0-9]$', but is 'xyz00'"); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(CLOUD_SERVER_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MANAGED_SERVER:xyz00.bookingItem' must be of type MANAGED_SERVER but is of type CLOUD_SERVER", + "'MANAGED_SERVER:xyz00.parentAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:xyz00.assignedToAsset' must be null but is of type MANAGED_SERVER"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..386282f6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java @@ -0,0 +1,181 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static org.assertj.core.api.Assertions.assertThat; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.PROJECT_TEST_ENTITY; + +@ExtendWith(MockitoExtension.class) +class HsManagedWebspaceHostingAssetValidatorUnitTest { + + final HsBookingItemRealEntity managedServerBookingItem = HsBookingItemRealEntity.builder() + .project(PROJECT_TEST_ENTITY) + .type(HsBookingItemType.MANAGED_SERVER) + .caption("Test Managed-Server") + .resources(Map.ofEntries( + entry("CPU", 2), + entry("RAM", 25), + entry("SSD", 25), + entry("Traffic", 250), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) + .build(); + final HsBookingItemRealEntity cloudServerBookingItem = managedServerBookingItem.toBuilder() + .type(HsBookingItemType.CLOUD_SERVER) + .caption("Test Cloud-Server") + .build(); + + final HsHostingAssetRealEntity mangedServerAssetEntity = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .bookingItem(managedServerBookingItem) + .identifier("vm1234") + .config(Map.ofEntries( + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) + )) + .build(); + final HsHostingAssetRealEntity cloudServerAssetEntity = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.CLOUD_SERVER) + .bookingItem(cloudServerBookingItem) + .identifier("vm1234") + .config(Map.ofEntries( + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) + )) + .build(); + + @Test + void acceptsAlienIdentifierPrefixForPreExistingEntity() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemRealEntity.builder() + .type(HsBookingItemType.MANAGED_WEBSPACE) + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) + .parentAsset(mangedServerAssetEntity) + .identifier("xyz00") + .isLoaded(true) + .build(); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> + validator.validateContext(mangedWebspaceHostingAssetEntity)); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesIdentifierAndReferencedEntities() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.MANAGED_WEBSPACE).build()) + .parentAsset(mangedServerAssetEntity) + .identifier("xyz00") + .build(); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).containsExactly("'identifier' expected to match '^abc[0-9][0-9]$', but is 'xyz00'"); + } + + @Test + void validatesUnknownProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.MANAGED_WEBSPACE).build()) + .parentAsset(mangedServerAssetEntity) + .identifier("abc00") + .config(Map.ofEntries( + entry("unknown", "some value") + )) + .build(); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).containsExactly("'MANAGED_WEBSPACE:abc00.config.unknown' is not expected but is set to 'some value'"); + } + + @Test + void validatesValidEntity() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemRealEntity.builder() + .type(HsBookingItemType.MANAGED_WEBSPACE) + .project(PROJECT_TEST_ENTITY) + .caption("some ManagedWebspace") + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) + .parentAsset(mangedServerAssetEntity) + .identifier("abc00") + .build(); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> + Stream.concat( + validator.validateEntity(mangedWebspaceHostingAssetEntity).stream(), + validator.validateContext(mangedWebspaceHostingAssetEntity).stream()) + .toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidEntityReferences() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemRealEntity.builder() + .type(HsBookingItemType.MANAGED_SERVER) + .caption("some ManagedServer") + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) + .parentAsset(cloudServerAssetEntity) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build()) + .identifier("abc00") + .build(); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).containsExactly( + "'MANAGED_WEBSPACE:abc00.bookingItem' must be of type MANAGED_WEBSPACE but is of type MANAGED_SERVER", + "'MANAGED_WEBSPACE:abc00.parentAsset' must be null or of type MANAGED_SERVER but is of type CLOUD_SERVER", + "'MANAGED_WEBSPACE:abc00.assignedToAsset' must be null but is of type CLOUD_SERVER"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..1d9cd8a1 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidatorUnitTest.java @@ -0,0 +1,122 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +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_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class HsMariaDbDatabaseHostingAssetValidatorUnitTest { + + private static final HsHostingAssetRealEntity GIVEN_MARIADB_INSTANCE = HsHostingAssetRealEntity.builder() + .type(MARIADB_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|MariaDB.default") + .caption("some valid test MariaDB-Instance") + .build(); + + private static final HsHostingAssetRealEntity GIVEN_MARIADB_USER = HsHostingAssetRealEntity.builder() + .type(MARIADB_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(GIVEN_MARIADB_INSTANCE) + .identifier("xyz00_temp") + .caption("some valid test MariaDB-User") + .config(new HashMap<>(ofEntries( + entry("password", "Hallo Datenbank, lass mich rein!") + ))) + .build(); + + private static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder givenValidMariaDbDatabaseBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(MARIADB_DATABASE) + .parentAsset(GIVEN_MARIADB_USER) + .identifier("MAD|xyz00_temp") + .caption("some valid test MariaDB-Database") + .config(new HashMap<>(ofEntries( + entry("encoding", "latin1") + ))); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(givenValidMariaDbDatabaseBuilder().build().getType()); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=string, propertyName=encoding, matchesRegEx=[[a-z0-9_]+], maxLength=24, provided=[latin1, utf8], defaultValue=utf8}" + ); + } + + @Test + void validatesValidEntity() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbDatabaseBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> Stream.concat( + validator.validateEntity(givenMariaDbUserHostingAsset).stream(), + validator.validateContext(givenMariaDbUserHostingAsset).stream() + ).toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbDatabaseBuilder() + .config(ofEntries( + entry("unknown", "wrong"), + entry("encoding", 10) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MARIADB_DATABASE:MAD|xyz00_temp.config.unknown' is not expected but is set to 'wrong'", + "'MARIADB_DATABASE:MAD|xyz00_temp.config.encoding' is expected to be of type String, but is of type Integer" + ); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbDatabaseBuilder() + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^MAD\\|xyz00$|^MAD\\|xyz00_[a-zA-Z0-9_]+$', but is 'xyz99-temp'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..c569a4cf --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidatorUnitTest.java @@ -0,0 +1,116 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetTestEntities.MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY; +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.validators.HsMariaDbInstanceHostingAssetValidator.DEFAULT_INSTANCE_IDENTIFIER_SUFFIX; +import static org.assertj.core.api.Assertions.assertThat; + +class HsMariaDbInstanceHostingAssetValidatorUnitTest { + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(MARIADB_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SMTP_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void preprocessesTakesIdentifierFromParent() { + // given + final var givenEntity = validEntityBuilder().build(); + assertThat(givenEntity.getParentAsset().getIdentifier()).as("precondition failed").isEqualTo("vm1234"); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + validator.preprocessEntity(givenEntity); + + // then + assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|MariaDB.default"); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier("example.org").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^\\Qvm1234|MariaDB.default\\E$', but is 'example.org'" + ); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // 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"); + } + + @Test + void rejectsInvalidProperties() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("any", "false") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MARIADB_INSTANCE:vm1234|MariaDB.default.config.any' is not expected but is set to 'false'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..ff882e91 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidatorUnitTest.java @@ -0,0 +1,133 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +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_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class HsMariaDbUserHostingAssetValidatorUnitTest { + + private static final HsHostingAssetRealEntity GIVEN_MARIADB_INSTANCE = HsHostingAssetRealEntity.builder() + .type(MARIADB_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|MariaDB.default") + .caption("some valid test MariaDB-Instance") + .build(); + + @Mock + private EntityManager em; + + private static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder givenValidMariaDbUserBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(MARIADB_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(GIVEN_MARIADB_INSTANCE) + .identifier("MAU|xyz00_temp") + .caption("some valid test MariaDB-User") + .config(new HashMap<>(ofEntries( + entry("password", "Test1234") + ))); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(givenValidMariaDbUserBuilder().build().getType()); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, computed=IN_PREP, hashedUsing=MYSQL_NATIVE, undisclosed=true}" + ); + } + + @Test + void preparesEntity() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + // HashGenerator.nextSalt("Ly3LbsArtL5u4EVt"); // not needed for mysql_native_password + validator.prepareProperties(em, givenMariaDbUserHostingAsset); + + // then + assertThat(givenMariaDbUserHostingAsset.getConfig()).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("password", "*14F1A8C42F8B6D4662BB3ED290FD37BF135FE45C") + )); + } + + @Test + void validatesValidEntity() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> Stream.concat( + validator.validateEntity(givenMariaDbUserHostingAsset).stream(), + validator.validateContext(givenMariaDbUserHostingAsset).stream() + ).toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder() + .config(ofEntries( + entry("unknown", 100), + entry("password", "short") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MARIADB_USER:MAU|xyz00_temp.config.unknown' is not expected but is set to '100'", + "'MARIADB_USER:MAU|xyz00_temp.config.password' length is expected to be at min 8 but length of provided value is 5", + "'MARIADB_USER:MAU|xyz00_temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters" + ); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder() + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^MAU\\|xyz00$|^MAU\\|xyz00_[a-zA-Z0-9_]+$', but is 'xyz99-temp'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..b20df86d --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidatorUnitTest.java @@ -0,0 +1,145 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +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_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE; +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.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class HsPostgreSqlDatabaseHostingAssetValidatorUnitTest { + + private static final HsHostingAssetRealEntity GIVEN_PGSQL_INSTANCE = HsHostingAssetRealEntity.builder() + .type(PGSQL_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|PgSql.default") + .caption("some valid test PgSql-Instance") + .build(); + + private static final HsHostingAssetRealEntity GIVEN_PGSQL_USER = HsHostingAssetRealEntity.builder() + .type(PGSQL_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(GIVEN_PGSQL_INSTANCE) + .identifier("xyz00_user") + .caption("some valid test PgSql-User") + .config(new HashMap<>(ofEntries( + entry("password", "Hallo Datenbank, lass mich rein!") + ))) + .build(); + + private static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder givenValidPgSqlDatabaseBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(PGSQL_DATABASE) + .parentAsset(GIVEN_PGSQL_USER) + .identifier("PGD|xyz00_db") + .caption("some valid test PgSql-Database") + .config(new HashMap<>(ofEntries( + entry("encoding", "LATIN1") + ))); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(givenValidPgSqlDatabaseBuilder().build().getType()); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=string, propertyName=encoding, matchesRegEx=[[A-Z0-9_]+], maxLength=24, provided=[LATIN1, UTF8], defaultValue=UTF8}" + ); + } + + @Test + void validatesValidEntity() { + // given + final var givenPgSqlUserHostingAsset = givenValidPgSqlDatabaseBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenPgSqlUserHostingAsset.getType()); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> Stream.concat( + validator.validateEntity(givenPgSqlUserHostingAsset).stream(), + validator.validateContext(givenPgSqlUserHostingAsset).stream() + ).toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferences() { + // given + final var givenPgSqlUserHostingAsset = givenValidPgSqlDatabaseBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(PGSQL_INSTANCE).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(PGSQL_INSTANCE).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenPgSqlUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenPgSqlUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'PGSQL_DATABASE:PGD|xyz00_db.bookingItem' must be null but is of type CLOUD_SERVER", + "'PGSQL_DATABASE:PGD|xyz00_db.parentAsset' must be of type PGSQL_USER but is of type PGSQL_INSTANCE", + "'PGSQL_DATABASE:PGD|xyz00_db.assignedToAsset' must be null but is of type PGSQL_INSTANCE" + ); + } + + @Test + void rejectsInvalidProperties() { + // given + final var givenPgSqlUserHostingAsset = givenValidPgSqlDatabaseBuilder() + .config(ofEntries( + entry("unknown", "wrong"), + entry("encoding", 10) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenPgSqlUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenPgSqlUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'PGSQL_DATABASE:PGD|xyz00_db.config.unknown' is not expected but is set to 'wrong'", + "'PGSQL_DATABASE:PGD|xyz00_db.config.encoding' is expected to be of type String, but is of type Integer" + ); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenPgSqlUserHostingAsset = givenValidPgSqlDatabaseBuilder() + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenPgSqlUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenPgSqlUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^PGD\\|xyz00$|^PGD\\|xyz00_[a-zA-Z0-9_]+$', but is 'xyz99-temp'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlInstanceHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlInstanceHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..e277d202 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlInstanceHostingAssetValidatorUnitTest.java @@ -0,0 +1,116 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +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 org.assertj.core.api.Assertions.assertThat; + +class HsPostgreSqlInstanceHostingAssetValidatorUnitTest { + + static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder validEntityBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(MARIADB_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SMTP_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void preprocessesTakesIdentifierFromParent() { + // given + final var givenEntity = validEntityBuilder().build(); + assertThat(givenEntity.getParentAsset().getIdentifier()).as("precondition failed").isEqualTo("vm1234"); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + validator.preprocessEntity(givenEntity); + + // then + assertThat(givenEntity.getIdentifier()).isEqualTo("vm1234|MariaDB.default"); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier("example.org").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^\\Qvm1234|MariaDB.default\\E$', but is 'example.org'" + ); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // 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"); + } + + @Test + void rejectsInvalidProperties() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("any", "false") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MARIADB_INSTANCE:vm1234|MariaDB.default.config.any' is not expected but is set to 'false'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..91b38508 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidatorUnitTest.java @@ -0,0 +1,131 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.EntityManager; +import java.nio.charset.Charset; +import java.util.Base64; +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +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_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY; +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.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class HsPostgreSqlUserHostingAssetValidatorUnitTest { + + private static final HsHostingAssetRealEntity GIVEN_PGSQL_INSTANCE = HsHostingAssetRealEntity.builder() + .type(PGSQL_INSTANCE) + .parentAsset(MANAGED_SERVER_HOSTING_ASSET_REAL_TEST_ENTITY) + .identifier("vm1234|PgSql.default") + .caption("some valid test PgSql-Instance") + .build(); + + private EntityManager em = null; // not actually needed in these test cases + + private static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder givenValidMariaDbUserBuilder() { + return HsHostingAssetRbacEntity.builder() + .type(PGSQL_USER) + .parentAsset(MANAGED_WEBSPACE_HOSTING_ASSET_REAL_TEST_ENTITY) + .assignedToAsset(GIVEN_PGSQL_INSTANCE) + .identifier("PGU|xyz00_temp") + .caption("some valid test PgSql-User") + .config(new HashMap<>(ofEntries( + entry("password", "Test1234") + ))); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(givenValidMariaDbUserBuilder().build().getType()); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, computed=IN_PREP, hashedUsing=SCRAM_SHA256, undisclosed=true}" + ); + } + + @Test + void preparesEntity() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + HashGenerator.nextSalt(new String(Base64.getDecoder().decode("L1QxSVNyTU81b3NZS1djNg=="), Charset.forName("latin1"))); + validator.prepareProperties(em, givenMariaDbUserHostingAsset); + + // then + assertThat(givenMariaDbUserHostingAsset.getConfig()).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("password", "SCRAM-SHA-256$4096:L1QxSVNyTU81b3NZS1djNg==$bB4PEqHpnkoB9FwYfOjh+8yJvLsCnrwxom3TGK0CVJM=:ACRgTfhJwIZLrzhVRbJ3Qif5YhErYWAfkBThvtouW+8=") + )); + } + + @Test + void validatesValidEntity() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> Stream.concat( + validator.validateEntity(givenMariaDbUserHostingAsset).stream(), + validator.validateContext(givenMariaDbUserHostingAsset).stream() + ).toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder() + .config(ofEntries( + entry("unknown", 100), + entry("password", "short") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'PGSQL_USER:PGU|xyz00_temp.config.unknown' is not expected but is set to '100'", + "'PGSQL_USER:PGU|xyz00_temp.config.password' length is expected to be at min 8 but length of provided value is 5", + "'PGSQL_USER:PGU|xyz00_temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters" + ); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenMariaDbUserHostingAsset = givenValidMariaDbUserBuilder() + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenMariaDbUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(givenMariaDbUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^PGU\\|xyz00$|^PGU\\|xyz00_[a-zA-Z0-9_]+$', but is 'xyz99-temp'"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..95a950db --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java @@ -0,0 +1,208 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.EntityManagerMock; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class HsUnixUserHostingAssetValidatorUnitTest { + + private final HsHostingAssetRealEntity TEST_MANAGED_SERVER_HOSTING_ASSET_REAL_ENTITY = HsHostingAssetRealEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed server") + .bookingItem(MANAGED_SERVER_BOOKING_ITEM_REAL_ENTITY) + .build(); + private final HsHostingAssetRealEntity TEST_MANAGED_WEBSPACE_HOSTING_ASSET_REAL_ENTITY = HsHostingAssetRealEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(TEST_MANAGED_SERVER_HOSTING_ASSET_REAL_ENTITY) + .identifier("abc00") + .build(); + private final HsHostingAssetRbacEntity TEST_MANAGED_WEBSPACE_HOSTING_ASSET_RBAC_ENTITY = HsHostingAssetRbacEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(MANAGED_WEBSPACE_BOOKING_ITEM_REAL_ENTITY) + .parentAsset(TEST_MANAGED_SERVER_HOSTING_ASSET_REAL_ENTITY) + .identifier("abc00") + .build(); + private final HsHostingAssetRbacEntity GIVEN_VALID_UNIX_USER_HOSTING_ASSET = HsHostingAssetRbacEntity.builder() + .type(UNIX_USER) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET_REAL_ENTITY) + .identifier("abc00-temp") + .caption("some valid test UnixUser") + .config(new HashMap<>(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("totpKey", "0x123456789abcdef01234"), + entry("password", "Hallo Computer, lass mich rein!") + ))) + .build(); + + @Mock + EntityManager em; + + @BeforeEach + void initMocks() { + final var nativeQueryMock = mock(Query.class); + lenient().when(nativeQueryMock.getSingleResult()).thenReturn(12345678); + lenient().when(em.createNativeQuery("SELECT nextval('hs_hosting_asset_unixuser_system_id_seq')", Integer.class)) + .thenReturn(nativeQueryMock); + + } + + @Test + void preparesUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + HashGenerator.nextSalt("Ly3LbsArtL5u4EVt"); + validator.prepareProperties(em, unixUserHostingAsset); + + // then + assertThat(unixUserHostingAsset.getConfig()).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("totpKey", "0x123456789abcdef01234"), + entry("password", "$6$Ly3LbsArtL5u4EVt$i/ayIEvm0y4bjkFB6wbg8imbRIaw4mAA4gqYRVyoSkj.iIxJKS3KiRkSjP8gweNcpKL0Q0N31EadT8fCnWErL."), + entry("userid", 12345678) + )); + } + + @Test + void validatesValidUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + final var em = EntityManagerMock.createEntityManagerMockWithAssetQueryFake(null); + + // when + final var result = HsEntityValidator.doWithEntityManager(em, () -> Stream.concat( + validator.validateEntity(unixUserHostingAsset).stream(), + validator.validateContext(unixUserHostingAsset).stream() + ).toList()); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesUnixUserProperties() { + // given + final var unixUserHostingAsset = HsHostingAssetRbacEntity.builder() + .type(UNIX_USER) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET_REAL_ENTITY) + .identifier("abc00-temp") + .caption("some test UnixUser with invalid properties") + .config(ofEntries( + entry("SSD hard quota", 60000), + entry("SSD soft quota", 70000), + entry("HDD hard quota", 100), + entry("HDD soft quota", 200), + entry("shell", "/is/invalid"), + entry("homedir", "/is/read-only"), + entry("totpKey", "should be a hex number"), + entry("password", "short") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(unixUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'UNIX_USER:abc00-temp.config.SSD hard quota' is expected to be at most 51200 but is 60000", + "'UNIX_USER:abc00-temp.config.SSD soft quota' is expected to be at most 60000 but is 70000", + "'UNIX_USER:abc00-temp.config.HDD hard quota' is expected to be at most 0 but is 100", + "'UNIX_USER:abc00-temp.config.HDD soft quota' is expected to be at most 100 but is 200", + "'UNIX_USER:abc00-temp.config.homedir' is readonly but given as '/is/read-only'", + "'UNIX_USER:abc00-temp.config.totpKey' is expected to match [^0x([0-9A-Fa-f]{2})+$] but provided value does not match", + "'UNIX_USER:abc00-temp.config.password' length is expected to be at min 8 but length of provided value is 5", + "'UNIX_USER:abc00-temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters" + ); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var unixUserHostingAsset = HsHostingAssetRbacEntity.builder() + .type(UNIX_USER) + .parentAsset(HsHostingAssetRealEntity.builder().type(MANAGED_WEBSPACE).identifier("abc00").build()) + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(unixUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^abc00$|^abc00-[a-z0-9\\._-]+$', but is 'xyz99-temp'"); + } + + @Test + void revampsUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + HashGenerator.nextSalt("Ly3LbsArtL5u4EVt"); + final var result = validator.revampProperties(em, unixUserHostingAsset, unixUserHostingAsset.getConfig()); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("homedir", "/home/pacs/abc00/users/temp") + )); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(UNIX_USER); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=boolean, propertyName=locked, readOnly=true}", + "{type=integer, propertyName=userid, readOnly=true, computed=IN_INIT}", + "{type=integer, propertyName=SSD hard quota, unit=MB, maxFrom=SSD}", + "{type=integer, propertyName=SSD soft quota, unit=MB, maxFrom=SSD hard quota}", + "{type=integer, propertyName=HDD hard quota, unit=MB, maxFrom=HDD}", + "{type=integer, propertyName=HDD soft quota, unit=MB, maxFrom=HDD hard quota}", + "{type=string, propertyName=shell, provided=[/bin/false, /bin/bash, /bin/csh, /bin/dash, /usr/bin/tcsh, /usr/bin/zsh, /usr/bin/passwd], defaultValue=/bin/false}", + "{type=string, propertyName=homedir, readOnly=true, computed=IN_REVAMP}", + "{type=string, propertyName=totpKey, matchesRegEx=[^0x([0-9A-Fa-f]{2})+$], minLength=20, maxLength=256, writeOnly=true, undisclosed=true}", + "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, computed=IN_PREP, hashedUsing=LINUX_SHA512, undisclosed=true}" + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java new file mode 100644 index 00000000..f00d57dd --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java @@ -0,0 +1,1209 @@ +package net.hostsharing.hsadminng.hs.migration; + +import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity; +import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType; +import net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionEntity; +import net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionType; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipStatus; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerDetailsEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; +import net.hostsharing.hsadminng.hs.office.sepamandate.HsOfficeSepaMandateEntity; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import java.io.Reader; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Arrays.stream; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.assertj.core.api.Fail.fail; + +/// Actual import of office data tables without config, for use as superclas of ImportOfficeData and ImportHostingAssets. +public abstract class BaseOfficeDataImport extends CsvDataImport { + + private static final String[] SUBSCRIBER_ROLES = new String[] { + "subscriber:operations-discussion", + "subscriber:operations-announce", + "subscriber:generalversammlung", + "subscriber:members-announce", + "subscriber:members-discussion", + "subscriber:customers-announce" + }; + private static final String[] KNOWN_ROLES = ArrayUtils.addAll( + new String[] { "partner", "vip-contact", "ex-partner", "billing", "contractual", "operation" }, + SUBSCRIBER_ROLES); + + // at least as the number of lines in business_partners.csv from test-data, but less than real data partner count + public static final int MAX_NUMBER_OF_TEST_DATA_PARTNERS = 100; + public static final int DELIBERATELY_BROKEN_BUSINESS_PARTNER_ID = 199; + + static int INITIAL_RELATION_ID = 2000000; + static int relationId = INITIAL_RELATION_ID; + + private static final List IGNORE_BUSINESS_PARTNERS = Arrays.asList( + 512167, // 11139, partner without contractual contact + 512170, // 11142, partner without contractual contact + 511725, // 10764, partner without contractual contact + // 512171, // 11143, partner without partner contact -- exception + -1 + ); + + private static final List IGNORE_CONTACTS = Arrays.asList( + 90547, // Kontakt hat keine Rolle + -1 + ); + + static Map contacts = new WriteOnceMap<>(); + static Map persons = new WriteOnceMap<>(); + static Map partners = new WriteOnceMap<>(); + static Map debitors = new WriteOnceMap<>(); + static Map memberships = new WriteOnceMap<>(); + + static Map relations = new WriteOnceMap<>(); + static Map sepaMandates = new WriteOnceMap<>(); + static Map bankAccounts = new WriteOnceMap<>(); + static Map coopShares = new WriteOnceMap<>(); + static Map coopAssets = new WriteOnceMap<>(); + + protected static void reset() { + contacts.clear(); + persons.clear(); + partners.clear(); + debitors.clear(); + memberships.clear(); + relations.clear(); + sepaMandates.clear(); + bankAccounts.clear(); + coopShares.clear(); + coopAssets.clear(); + relationId = INITIAL_RELATION_ID; + } + + @BeforeAll + static void resetOfficeImports() { + reset(); + } + + @Test + @Order(1) + void verifyInitialDatabase() { + // SQL DELETE for thousands of records takes too long, so we make sure, we only start with initial or test data + final var contactCount = (Integer) em.createNativeQuery("select count(*) from hs_office_contact", Integer.class) + .getSingleResult(); + assertThat(contactCount).isLessThan(20); + } + + @Test + @Order(1010) + void importBusinessPartners() { + + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/office/business_partners.csv")) { + final var lines = readAllLines(reader); + importBusinessPartners(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(1019) + void verifyBusinessPartners() { + assumeThatWeAreImportingControlledTestData(); + + // no contacts yet => mostly null values + assertThat(toJsonFormattedString(partners)).isEqualToIgnoringWhitespace(""" + { + 100=partner(P-10003: null null, null), + 120=partner(P-10020: null null, null), + 122=partner(P-11022: null null, null), + 132=partner(P-10152: null null, null), + 190=partner(P-19090: null null, null), + 199=partner(P-19999: null null, null), + 213=partner(P-10000: null null, null), + 541=partner(P-11018: null null, null), + 542=partner(P-11019: null null, null) + } + """); + assertThat(toJsonFormattedString(contacts)).isEqualTo("{}"); + assertThat(toJsonFormattedString(debitors)).isEqualToIgnoringWhitespace(""" + { + 100=debitor(D-1000300: rel(anchor='null null, null', type='DEBITOR'), mim), + 120=debitor(D-1002000: rel(anchor='null null, null', type='DEBITOR'), xyz), + 122=debitor(D-1102200: rel(anchor='null null, null', type='DEBITOR'), xxx), + 132=debitor(D-1015200: rel(anchor='null null, null', type='DEBITOR'), rar), + 190=debitor(D-1909000: rel(anchor='null null, null', type='DEBITOR'), yyy), + 199=debitor(D-1999900: rel(anchor='null null, null', type='DEBITOR'), zzz), + 213=debitor(D-1000000: rel(anchor='null null, null', type='DEBITOR'), hsh), + 541=debitor(D-1101800: rel(anchor='null null, null', type='DEBITOR'), wws), + 542=debitor(D-1101900: rel(anchor='null null, null', type='DEBITOR'), dph) + } + """); + assertThat(toJsonFormattedString(memberships)).isEqualToIgnoringWhitespace(""" + { + 100=Membership(M-1000300, P-10003, [2000-12-06,), ACTIVE), + 120=Membership(M-1002000, P-10020, [2000-12-06,2016-01-01), UNKNOWN), + 122=Membership(M-1102200, P-11022, [2021-04-01,), ACTIVE), + 132=Membership(M-1015200, P-10152, [2003-07-12,), ACTIVE), + 541=Membership(M-1101800, P-11018, [2021-05-17,), ACTIVE), + 542=Membership(M-1101900, P-11019, [2021-05-25,), ACTIVE) + } + """); + } + + @Test + @Order(1020) + void importContacts() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/office/contacts.csv")) { + final var lines = readAllLines(reader); + importContacts(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(1029) + void verifyContacts() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(partners)).isEqualToIgnoringWhitespace(""" + { + 100=partner(P-10003: ?? Michael Mellis, Herr Michael Mellis , Michael Mellis), + 120=partner(P-10020: LP JM GmbH, Herr Philip Meyer-Contract , JM GmbH), + 122=partner(P-11022: ?? Test PS, Petra Schmidt , Test PS), + 132=partner(P-10152: ?? Ragnar IT-Beratung, Herr Ragnar Richter , Ragnar IT-Beratung), + 190=partner(P-19090: NP Camus, Cecilia, Frau Cecilia Camus ), + 199=partner(P-19999: null null, null), + 213=partner(P-10000: LP Hostsharing e.G., Firma Hostmaster Hostsharing , Hostsharing e.G.), + 541=partner(P-11018: ?? Wasserwerk Südholstein, Frau Christiane Milberg , Wasserwerk Südholstein), + 542=partner(P-11019: ?? Das Perfekte Haus, Herr Richard Wiese , Das Perfekte Haus) + } + """); + assertThat(toJsonFormattedString(contacts)).isEqualToIgnoringWhitespace(""" + { + 100=contact(caption='Herr Michael Mellis , Michael Mellis', emailAddresses='{ "main": "michael@Mellis.example.org"}'), + 1200=contact(caption='JM e.K.', emailAddresses='{ "main": "jm-ex-partner@example.org"}'), + 1201=contact(caption='Frau Dr. Jenny Meyer-Billing , JM GmbH', emailAddresses='{ "main": "jm-billing@example.org"}'), + 1202=contact(caption='Herr Andrew Meyer-Operation , JM GmbH', emailAddresses='{ "main": "am-operation@example.org"}'), + 1203=contact(caption='Herr Philip Meyer-Contract , JM GmbH', emailAddresses='{ "main": "pm-partner@example.org"}'), + 1204=contact(caption='Frau Tammy Meyer-VIP , JM GmbH', emailAddresses='{ "main": "tm-vip@example.org"}'), + 1301=contact(caption='Petra Schmidt , Test PS', emailAddresses='{ "main": "ps@example.com"}'), + 132=contact(caption='Herr Ragnar Richter , Ragnar IT-Beratung', emailAddresses='{ "main": "hostsharing@ragnar-richter.de"}'), + 1401=contact(caption='Frau Frauke Fanninga ', emailAddresses='{ "main": "ff@example.org"}'), + 1501=contact(caption='Frau Cecilia Camus ', emailAddresses='{ "main": "cc@example.org"}'), + 212=contact(caption='Firma Hostmaster Hostsharing , Hostsharing e.G.', emailAddresses='{ "main": "hostmaster@hostsharing.net"}'), + 90436=contact(caption='Frau Christiane Milberg , Wasserwerk Südholstein', emailAddresses='{ "main": "rechnung@ww-sholst.example.org"}'), + 90437=contact(caption='Herr Richard Wiese , Das Perfekte Haus', emailAddresses='{ "main": "admin@das-perfekte-haus.example.org"}'), + 90438=contact(caption='Herr Karim Metzger , Wasswerwerk Südholstein', emailAddresses='{ "main": "karim.metzger@ww-sholst.example.org"}'), + 90590=contact(caption='Herr Inhaber R. Wiese , Das Perfekte Haus', emailAddresses='{ "main": "515217@kkemail.example.org"}'), + 90629=contact(caption='Ragnar Richter ', emailAddresses='{ "main": "mail@ragnar-richter..example.org"}'), + 90677=contact(caption='Eike Henning ', emailAddresses='{ "main": "hostsharing@eike-henning..example.org"}'), + 90698=contact(caption='Jan Henning ', emailAddresses='{ "main": "mail@jan-henning.example.org"}') + } + """); + assertThat(toJsonFormattedString(persons)).isEqualToIgnoringWhitespace(""" + { + 100=person(personType='??', tradeName='Michael Mellis', familyName='Mellis', givenName='Michael'), + 1200=person(personType='LP', tradeName='JM e.K.'), + 1201=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Billing', givenName='Jenny'), + 1202=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Operation', givenName='Andrew'), + 1203=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Contract', givenName='Philip'), + 1204=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-VIP', givenName='Tammy'), + 1301=person(personType='??', tradeName='Test PS', familyName='Schmidt', givenName='Petra'), + 132=person(personType='??', tradeName='Ragnar IT-Beratung', familyName='Richter', givenName='Ragnar'), + 1401=person(personType='NP', familyName='Fanninga', givenName='Frauke'), + 1501=person(personType='NP', familyName='Camus', givenName='Cecilia'), + 212=person(personType='LP', tradeName='Hostsharing e.G.', familyName='Hostsharing', givenName='Hostmaster'), + 90436=person(personType='??', tradeName='Wasserwerk Südholstein', familyName='Milberg', givenName='Christiane'), + 90437=person(personType='??', tradeName='Das Perfekte Haus', familyName='Wiese', givenName='Richard'), + 90438=person(personType='??', tradeName='Wasswerwerk Südholstein', familyName='Metzger', givenName='Karim'), + 90590=person(personType='??', tradeName='Das Perfekte Haus', familyName='Wiese', givenName='Inhaber R.'), + 90629=person(personType='NP', familyName='Richter', givenName='Ragnar'), + 90677=person(personType='NP', familyName='Henning', givenName='Eike'), + 90698=person(personType='NP', familyName='Henning', givenName='Jan') + } + """); + assertThat(toJsonFormattedString(debitors)).isEqualToIgnoringWhitespace(""" + { + 100=debitor(D-1000300: rel(anchor='?? Michael Mellis', type='DEBITOR', holder='?? Michael Mellis'), mim), + 120=debitor(D-1002000: rel(anchor='LP JM GmbH', type='DEBITOR', holder='LP JM GmbH'), xyz), + 122=debitor(D-1102200: rel(anchor='?? Test PS', type='DEBITOR', holder='?? Test PS'), xxx), + 132=debitor(D-1015200: rel(anchor='?? Ragnar IT-Beratung', type='DEBITOR', holder='?? Ragnar IT-Beratung'), rar), + 190=debitor(D-1909000: rel(anchor='NP Camus, Cecilia', type='DEBITOR', holder='NP Camus, Cecilia'), yyy), + 199=debitor(D-1999900: rel(anchor='null null, null', type='DEBITOR'), zzz), + 213=debitor(D-1000000: rel(anchor='LP Hostsharing e.G.', type='DEBITOR', holder='LP Hostsharing e.G.'), hsh), + 541=debitor(D-1101800: rel(anchor='?? Wasserwerk Südholstein', type='DEBITOR', holder='?? Wasserwerk Südholstein'), wws), + 542=debitor(D-1101900: rel(anchor='?? Das Perfekte Haus', type='DEBITOR', holder='?? Das Perfekte Haus'), dph) + } + """); + assertThat(toJsonFormattedString(memberships)).isEqualToIgnoringWhitespace(""" + { + 100=Membership(M-1000300, P-10003, [2000-12-06,), ACTIVE), + 120=Membership(M-1002000, P-10020, [2000-12-06,2016-01-01), UNKNOWN), + 122=Membership(M-1102200, P-11022, [2021-04-01,), ACTIVE), + 132=Membership(M-1015200, P-10152, [2003-07-12,), ACTIVE), + 541=Membership(M-1101800, P-11018, [2021-05-17,), ACTIVE), + 542=Membership(M-1101900, P-11019, [2021-05-25,), ACTIVE) + } + """); + assertThat(toJsonFormattedString(relations)).isEqualToIgnoringWhitespace(""" + { + 2000000=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000001=rel(anchor='?? Michael Mellis', type='DEBITOR', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000002=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='?? Ragnar IT-Beratung', contact='Herr Ragnar Richter , Ragnar IT-Beratung'), + 2000003=rel(anchor='?? Ragnar IT-Beratung', type='DEBITOR', holder='?? Ragnar IT-Beratung', contact='Herr Ragnar Richter , Ragnar IT-Beratung'), + 2000004=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='LP Hostsharing e.G.', contact='Firma Hostmaster Hostsharing , Hostsharing e.G.'), + 2000005=rel(anchor='LP Hostsharing e.G.', type='DEBITOR', holder='LP Hostsharing e.G.', contact='Firma Hostmaster Hostsharing , Hostsharing e.G.'), + 2000006=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000007=rel(anchor='?? Wasserwerk Südholstein', type='DEBITOR', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000008=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000009=rel(anchor='?? Das Perfekte Haus', type='DEBITOR', holder='?? Das Perfekte Haus', contact='Herr Inhaber R. Wiese , Das Perfekte Haus'), + 2000010=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), + 2000011=rel(anchor='LP JM GmbH', type='DEBITOR', holder='LP JM GmbH', contact='Frau Dr. Jenny Meyer-Billing , JM GmbH'), + 2000012=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='?? Test PS', contact='Petra Schmidt , Test PS'), + 2000013=rel(anchor='?? Test PS', type='DEBITOR', holder='?? Test PS', contact='Petra Schmidt , Test PS'), + 2000014=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='NP Camus, Cecilia', contact='Frau Cecilia Camus '), + 2000015=rel(anchor='NP Camus, Cecilia', type='DEBITOR', holder='NP Camus, Cecilia', contact='Frau Cecilia Camus '), + 2000016=rel(anchor='LP Hostsharing e.G.', type='PARTNER', holder='null null, null'), + 2000017=rel(anchor='null null, null', type='DEBITOR'), + 2000018=rel(anchor='LP Hostsharing e.G.', type='OPERATIONS', holder='LP Hostsharing e.G.', contact='Firma Hostmaster Hostsharing , Hostsharing e.G.'), + 2000019=rel(anchor='LP Hostsharing e.G.', type='REPRESENTATIVE', holder='LP Hostsharing e.G.', contact='Firma Hostmaster Hostsharing , Hostsharing e.G.'), + 2000020=rel(anchor='?? Michael Mellis', type='OPERATIONS', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000021=rel(anchor='?? Michael Mellis', type='REPRESENTATIVE', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000022=rel(anchor='?? Michael Mellis', type='SUBSCRIBER', mark='operations-discussion', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000023=rel(anchor='?? Michael Mellis', type='SUBSCRIBER', mark='operations-announce', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000024=rel(anchor='?? Michael Mellis', type='SUBSCRIBER', mark='generalversammlung', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000025=rel(anchor='?? Michael Mellis', type='SUBSCRIBER', mark='members-announce', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000026=rel(anchor='?? Michael Mellis', type='SUBSCRIBER', mark='members-discussion', holder='?? Michael Mellis', contact='Herr Michael Mellis , Michael Mellis'), + 2000027=rel(anchor='?? Ragnar IT-Beratung', type='OPERATIONS', holder='?? Ragnar IT-Beratung', contact='Herr Ragnar Richter , Ragnar IT-Beratung'), + 2000028=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='operations-discussion', holder='?? Ragnar IT-Beratung', contact='Herr Ragnar Richter , Ragnar IT-Beratung'), + 2000029=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='operations-announce', holder='?? Ragnar IT-Beratung', contact='Herr Ragnar Richter , Ragnar IT-Beratung'), + 2000030=rel(anchor='LP JM GmbH', type='EX_PARTNER', holder='LP JM e.K.', contact='JM e.K.'), + 2000031=rel(anchor='LP JM GmbH', type='OPERATIONS', holder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), + 2000032=rel(anchor='LP JM GmbH', type='VIP_CONTACT', holder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), + 2000033=rel(anchor='LP JM GmbH', type='SUBSCRIBER', mark='operations-announce', holder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), + 2000034=rel(anchor='LP JM GmbH', type='REPRESENTATIVE', holder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), + 2000035=rel(anchor='LP JM GmbH', type='SUBSCRIBER', mark='members-announce', holder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), + 2000036=rel(anchor='LP JM GmbH', type='SUBSCRIBER', mark='customers-announce', holder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), + 2000037=rel(anchor='LP JM GmbH', type='VIP_CONTACT', holder='LP JM GmbH', contact='Frau Tammy Meyer-VIP , JM GmbH'), + 2000038=rel(anchor='?? Test PS', type='OPERATIONS', holder='?? Test PS', contact='Petra Schmidt , Test PS'), + 2000039=rel(anchor='?? Test PS', type='REPRESENTATIVE', holder='?? Test PS', contact='Petra Schmidt , Test PS'), + 2000040=rel(anchor='LP JM GmbH', type='SUBSCRIBER', mark='operations-announce', holder='NP Fanninga, Frauke', contact='Frau Frauke Fanninga '), + 2000041=rel(anchor='NP Camus, Cecilia', type='OPERATIONS', holder='NP Camus, Cecilia', contact='Frau Cecilia Camus '), + 2000042=rel(anchor='NP Camus, Cecilia', type='REPRESENTATIVE', holder='NP Camus, Cecilia', contact='Frau Cecilia Camus '), + 2000043=rel(anchor='?? Wasserwerk Südholstein', type='REPRESENTATIVE', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000044=rel(anchor='?? Wasserwerk Südholstein', type='SUBSCRIBER', mark='generalversammlung', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000045=rel(anchor='?? Wasserwerk Südholstein', type='SUBSCRIBER', mark='members-announce', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000046=rel(anchor='?? Wasserwerk Südholstein', type='SUBSCRIBER', mark='members-discussion', holder='?? Wasserwerk Südholstein', contact='Frau Christiane Milberg , Wasserwerk Südholstein'), + 2000047=rel(anchor='?? Das Perfekte Haus', type='OPERATIONS', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000048=rel(anchor='?? Das Perfekte Haus', type='REPRESENTATIVE', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000049=rel(anchor='?? Das Perfekte Haus', type='SUBSCRIBER', mark='operations-discussion', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000050=rel(anchor='?? Das Perfekte Haus', type='SUBSCRIBER', mark='operations-announce', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000051=rel(anchor='?? Das Perfekte Haus', type='SUBSCRIBER', mark='generalversammlung', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000052=rel(anchor='?? Das Perfekte Haus', type='SUBSCRIBER', mark='members-announce', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000053=rel(anchor='?? Das Perfekte Haus', type='SUBSCRIBER', mark='members-discussion', holder='?? Das Perfekte Haus', contact='Herr Richard Wiese , Das Perfekte Haus'), + 2000054=rel(anchor='?? Wasserwerk Südholstein', type='OPERATIONS', holder='?? Wasswerwerk Südholstein', contact='Herr Karim Metzger , Wasswerwerk Südholstein'), + 2000055=rel(anchor='?? Wasserwerk Südholstein', type='SUBSCRIBER', mark='operations-discussion', holder='?? Wasswerwerk Südholstein', contact='Herr Karim Metzger , Wasswerwerk Südholstein'), + 2000056=rel(anchor='?? Wasserwerk Südholstein', type='SUBSCRIBER', mark='operations-announce', holder='?? Wasswerwerk Südholstein', contact='Herr Karim Metzger , Wasswerwerk Südholstein'), + 2000057=rel(anchor='?? Ragnar IT-Beratung', type='REPRESENTATIVE', holder='NP Richter, Ragnar', contact='Ragnar Richter '), + 2000058=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='generalversammlung', holder='NP Richter, Ragnar', contact='Ragnar Richter '), + 2000059=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='members-announce', holder='NP Richter, Ragnar', contact='Ragnar Richter '), + 2000060=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='members-discussion', holder='NP Richter, Ragnar', contact='Ragnar Richter '), + 2000061=rel(anchor='?? Ragnar IT-Beratung', type='OPERATIONS', holder='NP Henning, Eike', contact='Eike Henning '), + 2000062=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='operations-discussion', holder='NP Henning, Eike', contact='Eike Henning '), + 2000063=rel(anchor='?? Ragnar IT-Beratung', type='SUBSCRIBER', mark='operations-announce', holder='NP Henning, Eike', contact='Eike Henning '), + 2000064=rel(anchor='?? Ragnar IT-Beratung', type='OPERATIONS', holder='NP Henning, Jan', contact='Jan Henning ') + } + """); + } + + @Test + @Order(1030) + void importSepaMandates() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/office/sepa_mandates.csv")) { + final var lines = readAllLines(reader); + importSepaMandates(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(1039) + void verifySepaMandates() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(bankAccounts)).isEqualToIgnoringWhitespace(""" + { + 132=bankAccount(DE37500105177419788228: holder='Michael Mellis', bic='GENODEF1HH2'), + 234234=bankAccount(DE37500105177419788228: holder='Michael Mellis', bic='INGDDEFFXXX'), + 235600=bankAccount(DE02300209000106531065: holder='JM e.K.', bic='CMCIDEDD'), + 235662=bankAccount(DE49500105174516484892: holder='JM GmbH', bic='INGDDEFFXXX'), + 30=bankAccount(DE02300209000106531065: holder='Ragnar Richter', bic='GENODEM1GLS'), + 386=bankAccount(DE49500105174516484892: holder='Wasserwerk Suedholstein', bic='NOLADE21WHO'), + 387=bankAccount(DE89370400440532013000: holder='Richard Wiese Das Perfekte Haus', bic='COBADEFFXXX') + } + """); + assertThat(toJsonFormattedString(sepaMandates)).isEqualToIgnoringWhitespace(""" + { + 132=SEPA-Mandate(DE37500105177419788228, HS-10003-20140801, 2013-12-01, [2013-12-01,)), + 234234=SEPA-Mandate(DE37500105177419788228, MH12345, 2004-06-12, [2004-06-15,)), + 235600=SEPA-Mandate(DE02300209000106531065, JM33344, 2004-01-15, [2004-01-20,2005-06-28)), + 235662=SEPA-Mandate(DE49500105174516484892, JM33344, 2005-06-28, [2005-07-01,)), + 30=SEPA-Mandate(DE02300209000106531065, HS-10152-20140801, 2013-12-01, [2013-12-01,2016-02-16)), + 386=SEPA-Mandate(DE49500105174516484892, HS-11018-20210512, 2021-05-12, [2021-05-17,)), + 387=SEPA-Mandate(DE89370400440532013000, HS-11019-20210519, 2021-05-19, [2021-05-25,)) + } + """); + } + + @Test + @Order(1040) + void importCoopShares() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/office/share_transactions.csv")) { + final var lines = readAllLines(reader); + importCoopShares(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(1041) + void verifyCoopShares() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(coopShares)).isEqualToIgnoringWhitespace(""" + { + 241=CoopShareTransaction(M-1000300: 2011-12-05, SUBSCRIPTION, 16, 1000300), + 279=CoopShareTransaction(M-1015200: 2013-10-21, SUBSCRIPTION, 1, 1015200), + 33451=CoopShareTransaction(M-1002000: 2000-12-06, SUBSCRIPTION, 2, 1002000, initial share subscription), + 33701=CoopShareTransaction(M-1000300: 2005-01-10, SUBSCRIPTION, 40, 1000300, increase), + 33810=CoopShareTransaction(M-1002000: 2016-12-31, CANCELLATION, 22, 1002000, membership ended), + 3=CoopShareTransaction(M-1000300: 2000-12-06, SUBSCRIPTION, 80, 1000300, initial share subscription), + 523=CoopShareTransaction(M-1000300: 2020-12-08, SUBSCRIPTION, 96, 1000300, Kapitalerhoehung), + 562=CoopShareTransaction(M-1101800: 2021-05-17, SUBSCRIPTION, 4, 1101800, Beitritt), + 563=CoopShareTransaction(M-1101900: 2021-05-25, SUBSCRIPTION, 1, 1101900, Beitritt), + 721=CoopShareTransaction(M-1000300: 2023-10-10, SUBSCRIPTION, 96, 1000300, Kapitalerhoehung), + 90=CoopShareTransaction(M-1015200: 2003-07-12, SUBSCRIPTION, 1, 1015200) + } + """); + } + + @Test + @Order(1050) + void importCoopAssets() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/office/asset_transactions.csv")) { + final var lines = readAllLines(reader); + importCoopAssets(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(1059) + void verifyCoopAssets() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(coopAssets)).isEqualToIgnoringWhitespace(""" + { + 1093=CoopAssetsTransaction(M-1000300: 2023-10-05, DEPOSIT, 3072, 1000300, Kapitalerhoehung - Ueberweisung), + 1094=CoopAssetsTransaction(M-1000300: 2023-10-06, DEPOSIT, 3072, 1000300, Kapitalerhoehung - Ueberweisung), + 31000=CoopAssetsTransaction(M-1002000: 2000-12-06, DEPOSIT, 128.00, 1002000, for subscription B), + 32000=CoopAssetsTransaction(M-1000300: 2005-01-10, DEPOSIT, 2560.00, 1000300, for subscription C), + 33001=CoopAssetsTransaction(M-1000300: 2005-01-10, TRANSFER, -512.00, 1000300, for transfer to 10), + 33002=CoopAssetsTransaction(M-1002000: 2005-01-10, ADOPTION, 512.00, 1002000, for transfer from 7), + 34001=CoopAssetsTransaction(M-1002000: 2016-12-31, CLEARING, -8.00, 1002000, for cancellation D), + 34002=CoopAssetsTransaction(M-1002000: 2016-12-31, DISBURSAL, -100.00, 1002000, for cancellation D), + 34003=CoopAssetsTransaction(M-1002000: 2016-12-31, LOSS, -20.00, 1002000, for cancellation D), + 35001=CoopAssetsTransaction(M-1909000: 2024-01-15, DEPOSIT, 128.00, 1909000, for subscription E), + 35002=CoopAssetsTransaction(M-1909000: 2024-01-20, ADJUSTMENT, -128.00, 1909000, chargeback for subscription E, M-1909000:DEP:+128.00), + 358=CoopAssetsTransaction(M-1000300: 2000-12-06, DEPOSIT, 5120, 1000300, for subscription A), + 442=CoopAssetsTransaction(M-1015200: 2003-07-07, DEPOSIT, 64, 1015200), + 577=CoopAssetsTransaction(M-1000300: 2011-12-12, DEPOSIT, 1024, 1000300), + 632=CoopAssetsTransaction(M-1015200: 2013-10-21, DEPOSIT, 64, 1015200), + 885=CoopAssetsTransaction(M-1000300: 2020-12-15, DEPOSIT, 6144, 1000300, Einzahlung), + 924=CoopAssetsTransaction(M-1101800: 2021-05-21, DEPOSIT, 256, 1101800, Beitritt - Lastschrift), + 925=CoopAssetsTransaction(M-1101900: 2021-05-31, DEPOSIT, 64, 1101900, Beitritt - Lastschrift) + } + """); + } + + @Test + @Order(1099) + void verifyMemberships() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(memberships)).isEqualToIgnoringWhitespace(""" + { + 100=Membership(M-1000300, P-10003, [2000-12-06,), ACTIVE), + 120=Membership(M-1002000, P-10020, [2000-12-06,2016-01-01), UNKNOWN), + 122=Membership(M-1102200, P-11022, [2021-04-01,), ACTIVE), + 132=Membership(M-1015200, P-10152, [2003-07-12,), ACTIVE), + 190=Membership(M-1909000, P-19090, empty, INVALID), + 541=Membership(M-1101800, P-11018, [2021-05-17,), ACTIVE), + 542=Membership(M-1101900, P-11019, [2021-05-25,), ACTIVE) + } + """); + } + + @Test + @Order(2000) + void verifyAllPartnersHavePersons() { + partners.forEach((id, p) -> { + final var partnerRel = p.getPartnerRel(); + assertThat(partnerRel).describedAs("partner " + id + " without partnerRel").isNotNull(); + if (id != DELIBERATELY_BROKEN_BUSINESS_PARTNER_ID) { + logError(() -> { + assertThat(partnerRel.getContact()).describedAs("partner " + id + " without partnerRel.contact") + .isNotNull(); + assertThat(partnerRel.getContact().getCaption()).describedAs( + "partner " + id + " without valid partnerRel.contact").isNotNull(); + }); + logError(() -> { + assertThat(partnerRel.getHolder()).describedAs("partner " + id + " without partnerRel.relHolder") + .isNotNull(); + assertThat(partnerRel.getHolder().getPersonType()).describedAs( + "partner " + id + " without valid partnerRel.relHolder").isNotNull(); + }); + } + }); + } + + @Test + @Order(3001) + void removeSelfRepresentativeRelations() { + + // this happens if a natural person is marked as 'contractual' for itself + final var idsToRemove = new HashSet(); + relations.forEach((id, r) -> { + if (r.getHolder() == r.getAnchor()) { + idsToRemove.add(id); + } + }); + + // remove self-representatives + idsToRemove.forEach(id -> { + System.out.println("removing self representative relation: " + relations.get(id).toString()); + relations.remove(id); + }); + } + + @Test + @Order(3002) + void removeEmptyRelations() { + + // avoid a error when persisting the deliberately invalid partner entry #99 + final var idsToRemove = new HashSet(); + relations.forEach((id, r) -> { + if (r.getContact() == null || r.getContact().getCaption() == null || + r.getHolder() == null || r.getHolder().getPersonType() == null) { + idsToRemove.add(id); + } + }); + + // expected relations created from partner #99 + Hostsharing eG itself + idsToRemove.forEach(id -> { + System.out.println("removing unused relation: " + relations.get(id).toString()); + relations.remove(id); + }); + } + + @Test + @Order(3003) + void removeEmptyPartners() { + + // avoid a error when persisting the deliberately invalid partner entry #99 + final var idsToRemove = new HashSet(); + partners.forEach((id, r) -> { + final var partnerRole = r.getPartnerRel(); + + // such a record is in test data to test error messages + if (partnerRole.getContact() == null || partnerRole.getContact().getCaption() == null || + partnerRole.getHolder() == null | partnerRole.getHolder().getPersonType() == null) { + idsToRemove.add(id); + } + }); + + // expected partners created from partner #99 + Hostsharing eG itself + idsToRemove.forEach(id -> { + System.out.println("removing unused partner: " + partners.get(id).toString()); + partners.remove(id); + }); + } + + @Test + @Order(3004) + void removeEmptyDebitors() { + + // avoid a error when persisting the deliberately invalid partner entry #99 + final var idsToRemove = new HashSet(); + debitors.forEach((id, d) -> { + final var debitorRel = d.getDebitorRel(); + if (debitorRel.getContact() == null || debitorRel.getContact().getCaption() == null || + debitorRel.getAnchor() == null || debitorRel.getAnchor().getPersonType() == null || + debitorRel.getHolder() == null || debitorRel.getHolder().getPersonType() == null) { + idsToRemove.add(id); + } + }); + idsToRemove.forEach(id -> debitors.remove(id)); + + assumeThatWeAreImportingControlledTestData(); + assertThat(idsToRemove.size()).isEqualTo(1); // only from partner #99 + } + + @Test + @Order(3005) + void removeEmptyPersons() { + // avoid a error when persisting the deliberately invalid partner entry #99 + final var idsToRemove = new HashSet(); + persons.forEach((id, p) -> { + if (p.getPersonType() == null || + (p.getFamilyName() == null && p.getGivenName() == null && p.getTradeName() == null)) { + idsToRemove.add(id); + } + }); + idsToRemove.forEach(id -> persons.remove(id)); + + assumeThatWeAreImportingControlledTestData(); + assertThat(idsToRemove.size()).isEqualTo(0); + } + + @Test + @Order(9000) + @ContinueOnFailure + void logCollectedErrorsBeforePersist() { + assertNoErrors(); + } + + @Test + @Order(9010) + void persistOfficeEntities() { + + System.out.println("PERSISTING office data to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + deleteTestDataFromHsOfficeTables(); + resetHsOfficeSequences(); + deleteFromTestTables(); + deleteFromCommonTables(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + contacts.forEach(this::persist); + updateLegacyIds(contacts, "hs_office_contact_legacy_id", "contact_id"); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + persons.forEach(this::persist); + relations.forEach((id, rel) -> this.persist(id, rel.getAnchor())); + relations.forEach((id, rel) -> this.persist(id, rel.getHolder())); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + relations.forEach(this::persist); + }).assertSuccessful(); + + System.out.println("persisting " + partners.size() + " partners"); + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + partners.forEach((id, partner) -> { + // TODO: this is ugly and I don't know why it's suddenly necessary + partner.getPartnerRel().setAnchor(em.merge(partner.getPartnerRel().getAnchor())); + partner.getPartnerRel().setHolder(em.merge(partner.getPartnerRel().getHolder())); + partner.getPartnerRel().setContact(em.merge(partner.getPartnerRel().getContact())); + partner.setPartnerRel(em.merge(partner.getPartnerRel())); + em.persist(partner); + }); + updateLegacyIds(partners, "hs_office_partner_legacy_id", "bp_id"); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + debitors.forEach((id, debitor) -> { + debitor.setDebitorRel(em.merge(debitor.getDebitorRel())); + persist(id, debitor); + }); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + memberships.forEach(this::persist); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + bankAccounts.forEach(this::persist); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + sepaMandates.forEach(this::persist); + updateLegacyIds(sepaMandates, "hs_office_sepamandate_legacy_id", "sepa_mandate_id"); + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + coopShares.forEach(this::persist); + updateLegacyIds(coopShares, "hs_office_coopsharestransaction_legacy_id", "member_share_id"); + + }).assertSuccessful(); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + coopAssets.forEach(this::persist); + updateLegacyIds(coopAssets, "hs_office_coopassetstransaction_legacy_id", "member_asset_id"); + }).assertSuccessful(); + + } + + @Test + @Order(9190) + void verifyMembershipsActuallyPersisted() { + final var biCount = (Integer) em.createNativeQuery("select count(*) from hs_office_membership", Integer.class) + .getSingleResult(); + assertThat(biCount).isGreaterThan(isImportingControlledTestData() ? 5 : 300); + } + + private static boolean isImportingControlledTestData() { + return partners.size() <= MAX_NUMBER_OF_TEST_DATA_PARTNERS; + } + + private static void assumeThatWeAreImportingControlledTestData() { + assumeThat(partners.size()).isLessThanOrEqualTo(MAX_NUMBER_OF_TEST_DATA_PARTNERS); + } + + private void updateLegacyIds( + Map entities, + final String legacyIdTable, + final String legacyIdColumn) { + em.flush(); + entities.forEach((id, entity) -> em.createNativeQuery(""" + UPDATE ${legacyIdTable} + SET ${legacyIdColumn} = :legacyId + WHERE uuid = :uuid + """ + .replace("${legacyIdTable}", legacyIdTable) + .replace("${legacyIdColumn}", legacyIdColumn)) + .setParameter("legacyId", id) + .setParameter("uuid", entity.getUuid()) + .executeUpdate() + ); + } + + @Test + @Order(9999) + @ContinueOnFailure + void logCollectedErrors() { + this.assertNoErrors(); + } + + private void importBusinessPartners(final String[] header, final List records) { + + final var columns = new Columns(header); + + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final Integer bpId = rec.getInteger("bp_id"); + if (IGNORE_BUSINESS_PARTNERS.contains(bpId)) { + return; + } + + final var person = HsOfficePersonEntity.builder().build(); + + final var partnerRel = addRelation( + HsOfficeRelationType.PARTNER, + null, // is set after contacts when the person for 'Hostsharing eG' is known + person, + null // is set during contacts import depending on assigned roles + ); + + final var partner = HsOfficePartnerEntity.builder() + .partnerNumber(rec.getInteger("member_id")) + .details(HsOfficePartnerDetailsEntity.builder().build()) + .partnerRel(partnerRel) + .build(); + partners.put(bpId, partner); + + final var debitorRel = addRelation( + HsOfficeRelationType.DEBITOR, partnerRel.getHolder(), // partner person + null, // will be set in contacts import + null // will beset in contacts import + ); + + final var debitor = HsOfficeDebitorEntity.builder() + .debitorNumberSuffix("00") + .partner(partner) + .debitorRel(debitorRel) + .defaultPrefix(rec.getString("member_code").replace("hsh00-", "")) + .billable(rec.isEmpty("free") || rec.getString("free").equals("f")) + .vatReverseCharge(rec.getBoolean("exempt_vat")) + .vatBusiness("GROSS".equals(rec.getString("indicator_vat"))) // TODO: remove + .vatId(rec.getString("uid_vat")) + .build(); + debitors.put(bpId, debitor); + + if (isNotBlank(rec.getString("member_since"))) { + assertThat(rec.getInteger("member_id")).isEqualTo(partner.getPartnerNumber()); + final var membership = HsOfficeMembershipEntity.builder() + .partner(partner) + .memberNumberSuffix("00") + .validity(toPostgresDateRange( + rec.getLocalDate("member_since"), + rec.getLocalDate("member_until"))) + .membershipFeeBillable(rec.isEmpty("member_role")) + .status( + isBlank(rec.getString("member_until")) + ? HsOfficeMembershipStatus.ACTIVE + : HsOfficeMembershipStatus.UNKNOWN) + .build(); + memberships.put(bpId, membership); + } + }); + } + + private void importCoopShares(final String[] header, final List records) { + + final var columns = new Columns(header); + + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var bpId = rec.getInteger("bp_id"); + if (IGNORE_BUSINESS_PARTNERS.contains(bpId)) { + return; + } + + final var member = ofNullable(memberships.get(bpId)) + .orElseGet(() -> createOnDemandMembership(bpId)); + + final var shareTransaction = HsOfficeCoopSharesTransactionEntity.builder() + .membership(member) + .valueDate(rec.getLocalDate("date")) + .transactionType( + "SUBSCRIPTION".equals(rec.getString("action")) + ? HsOfficeCoopSharesTransactionType.SUBSCRIPTION + : "UNSUBSCRIPTION".equals(rec.getString("action")) + ? HsOfficeCoopSharesTransactionType.CANCELLATION + : HsOfficeCoopSharesTransactionType.ADJUSTMENT + ) + .shareCount(rec.getInteger("quantity")) + .comment(rec.getString("comment")) + .reference(member.getMemberNumber().toString()) + .build(); + + if (shareTransaction.getTransactionType() == HsOfficeCoopSharesTransactionType.ADJUSTMENT) { + final var negativeValue = -shareTransaction.getShareCount(); + final var adjustedShareTx = coopShares.values().stream().filter(a -> + a.getTransactionType() != HsOfficeCoopSharesTransactionType.ADJUSTMENT && + a.getMembership() == shareTransaction.getMembership() && + a.getShareCount() == negativeValue) + .findAny() + .orElseThrow(() -> new IllegalStateException( + "cannot determine share reverse entry for adjustment " + shareTransaction)); + shareTransaction.setAdjustedShareTx(adjustedShareTx); + } + coopShares.put(rec.getInteger("member_share_id"), shareTransaction); + }); + } + + private void importCoopAssets(final String[] header, final List records) { + + final var columns = new Columns(header); + + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var bpId = rec.getInteger("bp_id"); + + if (this.IGNORE_BUSINESS_PARTNERS.contains(bpId)) { + return; + } + + final var member = ofNullable(memberships.get(bpId)) + .orElseGet(() -> createOnDemandMembership(bpId)); + + final var assetTypeMapping = new HashMap() { + + { + put("ADJUSTMENT", HsOfficeCoopAssetsTransactionType.ADJUSTMENT); + put("HANDOVER", HsOfficeCoopAssetsTransactionType.TRANSFER); + put("ADOPTION", HsOfficeCoopAssetsTransactionType.ADOPTION); + put("LOSS", HsOfficeCoopAssetsTransactionType.LOSS); + put("CLEARING", HsOfficeCoopAssetsTransactionType.CLEARING); + put("PRESCRIPTION", HsOfficeCoopAssetsTransactionType.LIMITATION); + put("PAYBACK", HsOfficeCoopAssetsTransactionType.DISBURSAL); + put("PAYMENT", HsOfficeCoopAssetsTransactionType.DEPOSIT); + } + + public HsOfficeCoopAssetsTransactionType get(final String key) { + final var value = super.get(key); + if (value != null) { + return value; + } + throw new IllegalStateException("no mapping value found for: " + key); + } + }; + + final var assetTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .membership(member) + .valueDate(rec.getLocalDate("date")) + .transactionType(assetTypeMapping.get(rec.getString("action"))) + .assetValue(rec.getBigDecimal("amount")) + .comment(rec.getString("comment")) + .reference(member.getMemberNumber().toString()) + .build(); + + if (assetTransaction.getTransactionType() == HsOfficeCoopAssetsTransactionType.ADJUSTMENT) { + final var negativeValue = assetTransaction.getAssetValue().negate(); + final var adjustedAssetTx = coopAssets.values().stream().filter(a -> + a.getTransactionType() != HsOfficeCoopAssetsTransactionType.ADJUSTMENT && + a.getMembership() == assetTransaction.getMembership() && + a.getAssetValue().equals(negativeValue)) + .findAny() + .orElseThrow(() -> new IllegalStateException( + "cannot determine asset reverse entry for adjustment " + assetTransaction)); + assetTransaction.setAdjustedAssetTx(adjustedAssetTx); + } + + coopAssets.put(rec.getInteger("member_asset_id"), assetTransaction); + }); + } + + private static HsOfficeMembershipEntity createOnDemandMembership(final Integer bpId) { + final var onDemandMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("00") + .membershipFeeBillable(false) + .partner(partners.get(bpId)) + .status(HsOfficeMembershipStatus.INVALID) + .build(); + memberships.put(bpId, onDemandMembership); + return onDemandMembership; + } + + private void importSepaMandates(final String[] header, final List records) { + + final var columns = new Columns(header); + + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var debitor = debitors.get(rec.getInteger("bp_id")); + + if (this.IGNORE_BUSINESS_PARTNERS.contains(rec.getInteger("bp_id"))) { + return; + } + + final var sepaMandate = HsOfficeSepaMandateEntity.builder() + .debitor(debitor) + .bankAccount(HsOfficeBankAccountEntity.builder() + .holder(rec.getString("bank_customer")) + // .bankName(rec.get("bank_name")) // not supported + .iban(rec.getString("bank_iban")) + .bic(rec.getString("bank_bic")) + .build()) + .reference(rec.getString("mandat_ref")) + .agreement(LocalDate.parse(rec.getString("mandat_signed"))) + .validity(toPostgresDateRange( + rec.getLocalDate("mandat_since"), + rec.getLocalDate("mandat_until"))) + .build(); + + sepaMandates.put(rec.getInteger("sepa_mandat_id"), sepaMandate); + bankAccounts.put(rec.getInteger("sepa_mandat_id"), sepaMandate.getBankAccount()); + }); + } + + private void importContacts(final String[] header, final List records) { + + final var columns = new Columns(header); + + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var contactId = rec.getInteger("contact_id"); + final var bpId = rec.getInteger("bp_id"); + + if (IGNORE_CONTACTS.contains(contactId)) { + return; + } + if (IGNORE_BUSINESS_PARTNERS.contains(bpId)) { + return; + } + + if (rec.getString("roles").isBlank()) { + fail("empty roles assignment not allowed for contact_id: " + contactId); + } + + final var partner = partners.get(bpId); + final var debitor = debitors.get(bpId); + + final var partnerPerson = partner.getPartnerRel().getHolder(); + if (containsPartnerRel(rec)) { + addPerson(partnerPerson, rec); + } + + HsOfficePersonEntity contactPerson = partnerPerson; + if (!StringUtils.equals(rec.getString("firma"), partnerPerson.getTradeName()) || + !StringUtils.equals(rec.getString("first_name"), partnerPerson.getGivenName()) || + !StringUtils.equals(rec.getString("last_name"), partnerPerson.getFamilyName())) { + contactPerson = addPerson(HsOfficePersonEntity.builder().build(), rec); + } + + final var contact = HsOfficeContactRealEntity.builder().build(); + initContact(contact, rec); + + if (containsPartnerRel(rec)) { + assertThat(partner.getPartnerRel().getContact()).isNull(); + partner.getPartnerRel().setContact(contact); + } + if (containsRole(rec, "billing")) { + assertThat(debitor.getDebitorRel().getContact()).isNull(); + debitor.getDebitorRel().setHolder(contactPerson); + debitor.getDebitorRel().setContact(contact); + } + if (containsRole(rec, "operation")) { + addRelation(HsOfficeRelationType.OPERATIONS, partnerPerson, contactPerson, contact); + } + if (containsRole(rec, "contractual")) { + addRelation(HsOfficeRelationType.REPRESENTATIVE, partnerPerson, contactPerson, contact); + } + if (containsRole(rec, "ex-partner")) { + addRelation(HsOfficeRelationType.EX_PARTNER, partnerPerson, contactPerson, contact); + } + if (containsRole(rec, "vip-contact")) { + addRelation(HsOfficeRelationType.VIP_CONTACT, partnerPerson, contactPerson, contact); + } + for (String subscriberRole : SUBSCRIBER_ROLES) { + if (containsRole(rec, subscriberRole)) { + addRelation(HsOfficeRelationType.SUBSCRIBER, partnerPerson, contactPerson, contact) + .setMark(subscriberRole.split(":")[1]) + ; + } + } + verifyContainsOnlyKnownRoles(rec.getString("roles")); + }); + + assertNoMissingContractualRelations(); + useHostsharingAsPartnerAnchor(); + } + + private static void assertNoMissingContractualRelations() { + final var contractualMissing = new HashSet(); + partners.forEach((id, partner) -> { + final var partnerPerson = partner.getPartnerRel().getHolder(); + if (relations.values().stream() + .filter(rel -> rel.getAnchor() == partnerPerson && rel.getType() == HsOfficeRelationType.REPRESENTATIVE) + .findFirst().isEmpty()) { + contractualMissing.add(partner.getPartnerNumber()); + } + }); + if (isImportingControlledTestData()) { + assertThat(contractualMissing).containsOnly(19999); // deliberately wrong partner entry + } else { + assertThat(contractualMissing).as("partners without contractual contact found").isEmpty(); + } + } + + private static void useHostsharingAsPartnerAnchor() { + final var mandant = persons.values().stream() + .filter(p -> p.getTradeName().startsWith("Hostsharing e")) + .findFirst() + .orElseThrow(); + relations.values().stream() + .filter(r -> r.getType() == HsOfficeRelationType.PARTNER) + .forEach(r -> r.setAnchor(mandant)); + } + + private static boolean containsRole(final Record rec, final String role) { + final var roles = rec.getString("roles"); + return ("," + roles + ",").contains("," + role + ","); + } + + private static boolean containsPartnerRel(final Record rec) { + return containsRole(rec, "partner"); + } + + private static HsOfficeRelationRealEntity addRelation( + final HsOfficeRelationType type, + final HsOfficePersonEntity anchor, + final HsOfficePersonEntity holder, + final HsOfficeContactRealEntity contact) { + final var rel = HsOfficeRelationRealEntity.builder() + .anchor(anchor) + .holder(holder) + .contact(contact) + .type(type) + .build(); + relations.put(relationId++, rel); + return rel; + } + + private HsOfficePersonEntity addPerson(final HsOfficePersonEntity person, final Record contactRecord) { + // TODO: title+salutation: add to person + person.setGivenName(contactRecord.getString("first_name")); + person.setFamilyName(contactRecord.getString("last_name")); + person.setTradeName(contactRecord.getString("firma")); + determinePersonType(person, contactRecord.getString("roles")); + + persons.put(contactRecord.getInteger("contact_id"), person); + return person; + } + + private static void determinePersonType(final HsOfficePersonEntity person, final String roles) { + if (person.getTradeName().isBlank()) { + person.setPersonType(HsOfficePersonType.NATURAL_PERSON); + } else + // contractual && !partner with a firm and a natural person name + // should actually be split up into two persons + // but the legacy database consists such records + if (roles.contains("contractual") && !roles.contains("partner") && + !person.getFamilyName().isBlank() && !person.getGivenName().isBlank()) { + person.setPersonType(HsOfficePersonType.NATURAL_PERSON); + } else if (endsWithWord(person.getTradeName(), "e.K.", "e.G.", "eG", "GmbH", "AG", "KG")) { + person.setPersonType(HsOfficePersonType.LEGAL_PERSON); + } else if (endsWithWord(person.getTradeName(), "OHG")) { + person.setPersonType(HsOfficePersonType.INCORPORATED_FIRM); + } else if (endsWithWord(person.getTradeName(), "GbR")) { + person.setPersonType(HsOfficePersonType.INCORPORATED_FIRM); + } else { + person.setPersonType(HsOfficePersonType.UNKNOWN_PERSON_TYPE); + } + } + + private static boolean endsWithWord(final String value, final String... endings) { + final var lowerCaseValue = value.toLowerCase(); + for (String ending : endings) { + if (lowerCaseValue.endsWith(" " + ending.toLowerCase())) { + return true; + } + } + return false; + } + + private void verifyContainsOnlyKnownRoles(final String roles) { + final var allowedRolesSet = stream(KNOWN_ROLES).collect(Collectors.toSet()); + final var givenRolesSet = stream(roles.replace(" ", "").split(",")).collect(Collectors.toSet()); + final var unexpectedRolesSet = new HashSet<>(givenRolesSet); + unexpectedRolesSet.removeAll(allowedRolesSet); + assertThat(unexpectedRolesSet).isEmpty(); + } + + private HsOfficeContactRealEntity initContact(final HsOfficeContactRealEntity contact, final Record contactRecord) { + + contact.setCaption(toCaption( + contactRecord.getString("salut"), + contactRecord.getString("title"), + contactRecord.getString("first_name"), + contactRecord.getString("last_name"), + contactRecord.getString("firma"))); + contact.putEmailAddresses(Map.of("main", contactRecord.getString("email"))); + contact.setPostalAddress(toAddress(contactRecord)); + contact.putPhoneNumbers(toPhoneNumbers(contactRecord)); + + contacts.put(contactRecord.getInteger("contact_id"), contact); + return contact; + } + + private Map toPhoneNumbers(final Record rec) { + final var phoneNumbers = new LinkedHashMap(); + if (isNotBlank(rec.getString("phone_private"))) + phoneNumbers.put("phone_private", rec.getString("phone_private")); + if (isNotBlank(rec.getString("phone_office"))) + phoneNumbers.put("phone_office", rec.getString("phone_office")); + if (isNotBlank(rec.getString("phone_mobile"))) + phoneNumbers.put("phone_mobile", rec.getString("phone_mobile")); + if (isNotBlank(rec.getString("fax"))) + phoneNumbers.put("fax", rec.getString("fax")); + return phoneNumbers; + } + + private String toAddress(final Record rec) { + final var result = new StringBuilder(); + final var name = toName( + rec.getString("salut"), + rec.getString("title"), + rec.getString("first_name"), + rec.getString("last_name")); + if (isNotBlank(name)) + result.append(name + "\n"); + if (isNotBlank(rec.getString("firma"))) + result.append(rec.getString("firma") + "\n"); + if (isNotBlank(rec.getString("co"))) + result.append("c/o " + rec.getString("co") + "\n"); + if (isNotBlank(rec.getString("street"))) + result.append(rec.getString("street") + "\n"); + final var zipcodeAndCity = toZipcodeAndCity(rec); + if (isNotBlank(zipcodeAndCity)) + result.append(zipcodeAndCity + "\n"); + return result.toString(); + } + + private String toZipcodeAndCity(final Record rec) { + final var result = new StringBuilder(); + if (isNotBlank(rec.getString("country"))) + result.append(rec.getString("country") + " "); + if (isNotBlank(rec.getString("zipcode"))) + result.append(rec.getString("zipcode") + " "); + if (isNotBlank(rec.getString("city"))) + result.append(rec.getString("city")); + return result.toString(); + } + + private String toCaption( + final String salut, + final String title, + final String firstname, + final String lastname, + final String firm) { + final var result = new StringBuilder(); + if (isNotBlank(salut)) + result.append(salut + " "); + if (isNotBlank(title)) + result.append(title + " "); + if (isNotBlank(firstname)) + result.append(firstname + " "); + if (isNotBlank(lastname)) + result.append(lastname + " "); + if (isNotBlank(firm)) { + result.append((isBlank(result) ? "" : ", ") + firm); + } + return result.toString(); + } + + private String toName(final String salut, final String title, final String firstname, final String lastname) { + return toCaption(salut, title, firstname, lastname, null); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java new file mode 100644 index 00000000..d10f3577 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java @@ -0,0 +1,433 @@ +package net.hostsharing.hsadminng.hs.migration; + +import com.opencsv.CSVParserBuilder; +import com.opencsv.CSVReader; +import com.opencsv.CSVReaderBuilder; +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +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.boot.test.mock.mockito.MockBean; +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.IOException; +import java.io.InputStream; +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.time.LocalDate; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +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; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.junit.jupiter.api.Assertions.fail; + +public class CsvDataImport extends ContextBasedTest { + + public static final String TEST_DATA_MIGRATION_DATA_PATH = "migration"; + public static final String MIGRATION_DATA_PATH = ofNullable(System.getenv("HSADMINNG_MIGRATION_DATA_PATH")) + .orElse(TEST_DATA_MIGRATION_DATA_PATH); + + @Value("${spring.datasource.url}") + protected String jdbcUrl; + + @Value("${spring.datasource.username}") + protected String postgresAdminUser; + + @Value("${hsadminng.superuser}") + protected String rbacSuperuser; + + @PersistenceContext + EntityManager em; + + @Autowired + TransactionTemplate txTemplate; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + static final LinkedHashSet errors = new LinkedHashSet<>(); + + public List readAllLines(Reader reader) throws Exception { + + final var parser = new CSVParserBuilder() + .withSeparator(';') + .withQuoteChar('"') + .build(); + + final var filteredReader = skippingEmptyAndCommentLines(reader); + try (CSVReader csvReader = new CSVReaderBuilder(filteredReader) + .withCSVParser(parser) + .build()) { + return csvReader.readAll(); + } + } + + public static Reader skippingEmptyAndCommentLines(Reader reader) throws IOException { + try (var bufferedReader = new BufferedReader(reader); + StringWriter writer = new StringWriter()) { + + String line; + while ((line = bufferedReader.readLine()) != null) { + if (!line.isBlank() && !line.startsWith("#")) { + writer.write(line); + writer.write("\n"); + } + } + + return new StringReader(writer.toString()); + } + } + + protected static String[] justHeader(final List lines) { + return stream(lines.getFirst()).map(String::trim).toArray(String[]::new); + } + + protected Reader resourceReader(@NotNull final String resourcePath) { + try { + return new InputStreamReader(requireNonNull(getClass().getClassLoader().getResourceAsStream(resourcePath))); + } catch (Exception exc) { + throw new AssertionFailedError("cannot open '" + resourcePath + "'"); + } + } + + protected String resourceAsString(@NotNull final String resourcePath) { + try (InputStream inputStream = requireNonNull(getClass().getClassLoader().getResourceAsStream(resourcePath)); + final var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining(System.lineSeparator())); + } catch (Exception exc) { + throw new AssertionFailedError("cannot open '" + resourcePath + "'"); + } + } + + protected List withoutHeader(final List records) { + return records.subList(1, records.size()); + } + + @SneakyThrows + public static String[] parseCsvLine(final String csvLine) { + try (final var reader = new CSVReader(new StringReader(csvLine))) { + return stream(ofNullable(reader.readNext()).orElse(emptyArray(String.class))) + .map(String::trim) + .map(target -> target.startsWith("'") && target.endsWith("'") ? + target.substring(1, target.length() - 1) : + target) + .toArray(String[]::new); + } + } + + String[] trimAll(final String[] record) { + for (int i = 0; i < record.length; ++i) { + if (record[i] != null) { + record[i] = record[i].trim(); + } + } + return record; + } + + public T persist(final Integer id, final T entity) { + try { + if (entity instanceof HsHostingAsset ha) { + //noinspection unchecked + return (T) persistViaSql(id, ha); + } + return persistViaEM(id, entity); + } catch (Exception exc) { + errors.add("failed to persist #" + entity.hashCode() + ": " + entity); + errors.add(exc.toString()); + } + return entity; + } + + public T persistViaEM(final Integer id, final T entity) { + //System.out.println("persisting #" + entity.hashCode() + ": " + entity); + em.persist(entity); + // uncomment for debugging purposes + // em.flush(); // makes it slow, but produces better error messages + // System.out.println("persisted #" + entity.hashCode() + " as " + entity.getUuid()); + return entity; + } + + @SneakyThrows + public BaseEntity persistViaSql(final Integer id, final HsHostingAsset entity) { + if (entity.getUuid() == null) { + entity.setUuid(UUID.randomUUID()); + } + + final var query = em.createNativeQuery(""" + insert into hs_hosting_asset( + uuid, + type, + bookingitemuuid, + parentassetuuid, + assignedtoassetuuid, + alarmcontactuuid, + identifier, + caption, + config, + version) + values ( + :uuid, + :type, + :bookingitemuuid, + :parentassetuuid, + :assignedtoassetuuid, + :alarmcontactuuid, + :identifier, + :caption, + cast(:config as jsonb), + :version) + """) + .setParameter("uuid", entity.getUuid()) + .setParameter("type", entity.getType().name()) + .setParameter("bookingitemuuid", ofNullable(entity.getBookingItem()).map(BaseEntity::getUuid).orElse(null)) + .setParameter("parentassetuuid", ofNullable(entity.getParentAsset()).map(BaseEntity::getUuid).orElse(null)) + .setParameter( + "assignedtoassetuuid", + ofNullable(entity.getAssignedToAsset()).map(BaseEntity::getUuid).orElse(null)) + .setParameter("alarmcontactuuid", ofNullable(entity.getAlarmContact()).map(BaseEntity::getUuid).orElse(null)) + .setParameter("identifier", entity.getIdentifier()) + .setParameter("caption", entity.getCaption()) + .setParameter("config", entity.getConfig().toString().replace("\t", "\\t")) + .setParameter("version", entity.getVersion()); + + final var count = query.executeUpdate(); + logError(() -> { + assertThat(count).isEqualTo(1); + }); + return entity; + } + + protected String toJsonFormattedString(final Map map) { + if ( map.isEmpty() ) { + return "{}"; + } + final var json = "{\n" + + map.keySet().stream() + .map(id -> " " + id + "=" + map.get(id).toString()) + .map(e -> e.replaceAll("\n ", " ").replace("\n", "").replace(" : ", ": ").replace("{ ", "{").replace(", ", ", ")) + .sorted() + .collect(Collectors.joining(",\n")) + + "\n}\n"; + return json; + } + + protected void deleteTestDataFromHsOfficeTables() { + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + // TODO.perf: could we instead skip creating test-data based on an env var? + em.createNativeQuery("delete from hs_hosting_asset where true").executeUpdate(); + em.createNativeQuery("delete from hs_hosting_asset_ex where true").executeUpdate(); + em.createNativeQuery("delete from hs_booking_item where true").executeUpdate(); + em.createNativeQuery("delete from hs_booking_item_ex where true").executeUpdate(); + em.createNativeQuery("delete from hs_booking_project where true").executeUpdate(); + em.createNativeQuery("delete from hs_booking_project_ex where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_coopassetstransaction where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_coopassetstransaction_legacy_id where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_coopsharestransaction where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_coopsharestransaction_legacy_id where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_membership where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_sepamandate where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_sepamandate_legacy_id where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_debitor where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_bankaccount where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_partner where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_partner_details where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_relation where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_contact where true").executeUpdate(); + em.createNativeQuery("delete from hs_office_person where true").executeUpdate(); + }).assertSuccessful(); + } + + protected void resetHsOfficeSequences() { + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + em.createNativeQuery("alter sequence hs_office_contact_legacy_id_seq restart with 1000000000;").executeUpdate(); + em.createNativeQuery("alter sequence hs_office_coopassetstransaction_legacy_id_seq restart with 1000000000;") + .executeUpdate(); + em.createNativeQuery("alter sequence public.hs_office_coopsharestransaction_legacy_id_seq restart with 1000000000;") + .executeUpdate(); + em.createNativeQuery("alter sequence public.hs_office_partner_legacy_id_seq restart with 1000000000;") + .executeUpdate(); + em.createNativeQuery("alter sequence public.hs_office_sepamandate_legacy_id_seq restart with 1000000000;") + .executeUpdate(); + }); + } + + protected void deleteFromTestTables() { + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + em.createNativeQuery("delete from test_domain where true").executeUpdate(); + em.createNativeQuery("delete from test_package where true").executeUpdate(); + em.createNativeQuery("delete from test_customer where true").executeUpdate(); + }).assertSuccessful(); + } + + protected void deleteFromCommonTables() { + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + em.createNativeQuery("delete from rbacuser_rv where name not like 'superuser-%'").executeUpdate(); + em.createNativeQuery("delete from tx_journal where true").executeUpdate(); + em.createNativeQuery("delete from tx_context where true").executeUpdate(); + }).assertSuccessful(); + } + + // makes it possible to fail when an expression is expected + T failWith(final String message) { + fail(message); + return null; + } + + void logError(final Runnable assertion) { + try { + assertion.run(); + } catch (final AssertionError | ValidationException exc) { + logError(exc.getMessage()); + } + } + + public static void logError(final String error) { + errors.add(error); + } + + protected static void expectError(final String expectedError) { + final var found = errors.remove(expectedError); + if (!found) { + logError("expected but not found: " + expectedError); + } + } + + protected final void assertNoErrors() { + final var errorsToLog = new LinkedHashSet<>(errors); + errors.clear(); + assertThat(errorsToLog).isEmpty(); + } +} + +class Columns { + + private final List columnNames; + + public Columns(final String[] header) { + columnNames = List.of(header); + } + + int indexOf(final String columnName) { + return columnNames.indexOf(columnName); + } +} + +class Record { + + private final Columns columns; + private final String[] row; + + public Record(final Columns columns, final String[] row) { + this.columns = columns; + this.row = row; + } + + String getString(final String columnName, final String defaultValue) { + final var index = columns.indexOf(columnName); + final var value = index >= 0 && index < row.length ? row[index].trim() : null; + return value != null ? value : defaultValue; + } + + String getString(final String columnName) { + return row[columns.indexOf(columnName)].trim(); + } + + boolean isEmpty(final String columnName) { + final String value = getString(columnName); + return value == null || value.isBlank(); + } + + boolean getBoolean(final String columnName) { + final String value = getString(columnName); + return isNotBlank(value) && + (parseBoolean(value.trim()) || value.trim().startsWith("t")); + } + + Integer getInteger(final String columnName) { + final String value = getString(columnName); + return isNotBlank(value) ? Integer.parseInt(value.trim()) : null; + } + + BigDecimal getBigDecimal(final String columnName) { + final String value = getString(columnName); + if (isNotBlank(value)) { + return new BigDecimal(value); + } + return null; + } + + LocalDate getLocalDate(final String columnName) { + final String dateString = getString(columnName); + if (isNotBlank(dateString)) { + return LocalDate.parse(dateString); + } + return null; + } +} + +@Retention(RetentionPolicy.RUNTIME) +@interface ContinueOnFailure { +} + +class OrderedDependedTestsExtension implements TestWatcher, BeforeEachCallback { + + private static boolean previousTestsPassed = true; + + @Override + public void testFailed(final ExtensionContext context, final Throwable cause) { + previousTestsPassed = previousTestsPassed && context.getElement() + .map(e -> e.isAnnotationPresent(ContinueOnFailure.class)) + .orElse(false); + } + + @Override + public void beforeEach(final ExtensionContext extensionContext) { + assumeThat(previousTestsPassed).isTrue(); + } +} + +class WriteOnceMap extends TreeMap { + + @Override + public V put(final K k, final V v) { + assertThat(containsKey(k)).describedAs("overwriting " + get(k) + " index " + k + " with " + v).isFalse(); + return super.put(k, v); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java new file mode 100644 index 00000000..d3ed3407 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java @@ -0,0 +1,1761 @@ +package net.hostsharing.hsadminng.hs.migration; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hash.HashGenerator; +import net.hostsharing.hsadminng.hash.HashGenerator.Algorithm; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity; +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.test.JpaAttempt; +import org.apache.commons.collections4.ListUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.reflections.Reflections; +import org.reflections.scanners.ResourcesScanner; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.Commit; +import org.springframework.test.annotation.DirtiesContext; + +import java.io.Reader; +import java.net.IDN; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Arrays.stream; +import static java.util.Map.entry; +import static java.util.Map.ofEntries; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ALIAS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV4_NUMBER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE; +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.mapper.PostgresDateRange.toPostgresDateRange; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +/* + * This 'test' includes the complete legacy 'office' data import. + * + * There is no code in 'main' because the import is not needed a normal runtime. + * There is some test data in Java resources to verify the data conversion. + * For a real import a main method will be added later + * which reads CSV files from the file system. + * + * When run on a Hostsharing database, it needs the following settings (hsh99_... just examples). + * + * In a real Hostsharing environment, these are created via (the old) hsadmin: + + CREATE USER hsh99_admin WITH PASSWORD 'password'; + CREATE DATABASE hsh99_hsadminng ENCODING 'UTF8' TEMPLATE template0; + REVOKE ALL ON DATABASE hsh99_hsadminng FROM public; -- why does hsadmin do that? + ALTER DATABASE hsh99_hsadminng OWNER TO hsh99_admin; + + CREATE USER hsh99_restricted WITH PASSWORD 'password'; + + \c hsh99_hsadminng + + GRANT ALL PRIVILEGES ON SCHEMA public to hsh99_admin; + + * Additionally, we need these settings (because the Hostsharing DB-Admin has no CREATE right): + + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + -- maybe something like that is needed for the 2nd user + -- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public to hsh99_restricted; + + * Then copy the file .tc-environment to a file named .environment (excluded from git) and fill in your specific values. + + * To finally import the office data, run: + * + * gw-importHostingAssets # comes from .aliases file and uses .environment + */ +@Tag("importHostingAssets") +@DataJpaTest(properties = { + "spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///importHostingAssetsTC}", + "spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}", + "spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}", + "hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}" +}) +@DirtiesContext +@Import({ Context.class, JpaAttempt.class }) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@ExtendWith(OrderedDependedTestsExtension.class) +public class ImportHostingAssets extends BaseOfficeDataImport { + + private static final Set NOBODY_SUBSTITUTES = Set.of("nomail", "bounce"); + + static List zonefileErrors = new ArrayList<>(); + + record Hive(int hive_id, String hive_name, int inet_addr_id, AtomicReference serverRef) {} + + static Map bookingProjects = new WriteOnceMap<>(); + static Map bookingItems = new WriteOnceMap<>(); + static Map hives = new WriteOnceMap<>(); + + static Map ipNumberAssets = new WriteOnceMap<>(); + static Map packetAssets = new WriteOnceMap<>(); + static Map unixUserAssets = new WriteOnceMap<>(); + static Map emailAliasAssets = new WriteOnceMap<>(); + static Map dbInstanceAssets = new WriteOnceMap<>(); + static Map dbUserAssets = new WriteOnceMap<>(); + static Map dbAssets = new WriteOnceMap<>(); + static Map domainSetupAssets = new WriteOnceMap<>(); + static Map domainDnsSetupAssets = new WriteOnceMap<>(); + static Map domainHttpSetupAssets = new WriteOnceMap<>(); + static Map domainMBoxSetupAssets = new WriteOnceMap<>(); + static Map domainSmtpSetupAssets = new WriteOnceMap<>(); + static Map emailAddressAssets = new WriteOnceMap<>(); + + static Map dbUsersByEngineAndName = new WriteOnceMap<>(); + static Map domainSetupsByName = new WriteOnceMap<>(); + + final ObjectMapper jsonMapper = new ObjectMapper(); + + @Test + @Order(11010) + void createBookingProjects() { + debitors.forEach((id, debitor) -> { + bookingProjects.put(id, HsBookingProjectRealEntity.builder() + .caption(debitor.getDefaultPrefix() + " default project") + .debitor(em.find(HsBookingDebitorEntity.class, debitor.getUuid())) + .build()); + }); + } + + @Test + @Order(12010) + void importIpNumbers() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/inet_addr.csv")) { + final var lines = readAllLines(reader); + importIpNumbers(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(12019) + void verifyIpNumbers() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(5, ipNumberAssets)).isEqualToIgnoringWhitespace(""" + { + 363=HsHostingAsset(IPV4_NUMBER, 83.223.95.34), + 381=HsHostingAsset(IPV4_NUMBER, 83.223.95.52), + 401=HsHostingAsset(IPV4_NUMBER, 83.223.95.72), + 402=HsHostingAsset(IPV4_NUMBER, 83.223.95.73), + 433=HsHostingAsset(IPV4_NUMBER, 83.223.95.104) + } + """); + } + + @Test + @Order(12030) + void importHives() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/hive.csv")) { + final var lines = readAllLines(reader); + importHives(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(12039) + void verifyHives() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(toJsonFormattedString(first(5, hives))).isEqualToIgnoringWhitespace(""" + { + 1001=Hive[hive_id=1001, hive_name=h00, inet_addr_id=358, serverRef=null], + 1002=Hive[hive_id=1002, hive_name=h01, inet_addr_id=359, serverRef=null], + 1004=Hive[hive_id=1004, hive_name=h02, inet_addr_id=360, serverRef=null], + 1007=Hive[hive_id=1007, hive_name=h03, inet_addr_id=361, serverRef=null], + 1013=Hive[hive_id=1013, hive_name=h04, inet_addr_id=430, serverRef=null] + } + """); + } + + @Test + @Order(13000) + void importPackets() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/packet.csv")) { + final var lines = readAllLines(reader); + importPackets(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(13009) + void verifyPackets() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEachType( + 3, + HsBookingItemType.CLOUD_SERVER, + HsBookingItemType.MANAGED_SERVER, + HsBookingItemType.MANAGED_WEBSPACE)).isEqualToIgnoringWhitespace(""" + { + 10630=HsBookingItem(MANAGED_WEBSPACE, BI hsh00, D-1000000:hsh default project, [2001-06-01,)), + 10968=HsBookingItem(MANAGED_SERVER, BI vm1061, D-1015200:rar default project, [2013-04-01,)), + 10978=HsBookingItem(MANAGED_SERVER, BI vm1050, D-1000000:hsh default project, [2013-04-01,)), + 11061=HsBookingItem(MANAGED_SERVER, BI vm1068, D-1000300:mim default project, [2013-08-19,)), + 11094=HsBookingItem(MANAGED_WEBSPACE, BI lug00, D-1000300:mim default project, [2013-09-10,)), + 11111=HsBookingItem(MANAGED_WEBSPACE, BI xyz68, D-1000000:vm1068 Monitor, [2013-08-19,)), + 23611=HsBookingItem(CLOUD_SERVER, BI vm2097, D-1101800:wws default project, [2022-08-10,)) + } + """); + assertThat(firstOfEach(9, packetAssets)).isEqualToIgnoringWhitespace(""" + { + 10630=HsHostingAsset(MANAGED_WEBSPACE, hsh00, HA hsh00, MANAGED_SERVER:vm1050, D-1000000:hsh default project:BI hsh00), + 10968=HsHostingAsset(MANAGED_SERVER, vm1061, HA vm1061, D-1015200:rar default project:BI vm1061), + 10978=HsHostingAsset(MANAGED_SERVER, vm1050, HA vm1050, D-1000000:hsh default project:BI vm1050), + 11061=HsHostingAsset(MANAGED_SERVER, vm1068, HA vm1068, D-1000300:mim default project:BI vm1068), + 11094=HsHostingAsset(MANAGED_WEBSPACE, lug00, HA lug00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI lug00), + 11111=HsHostingAsset(MANAGED_WEBSPACE, xyz68, HA xyz68, MANAGED_SERVER:vm1068, D-1000000:vm1068 Monitor:BI xyz68), + 11112=HsHostingAsset(MANAGED_WEBSPACE, mim00, HA mim00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI mim00), + 11447=HsHostingAsset(MANAGED_SERVER, vm1093, HA vm1093, D-1000000:hsh default project:BI vm1093), + 19959=HsHostingAsset(MANAGED_WEBSPACE, dph00, HA dph00, MANAGED_SERVER:vm1093, D-1101900:dph default project:BI dph00) + } + """); + } + + @Test + @Order(13010) + void importPacketComponents() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/packet_component.csv")) { + final var lines = readAllLines(reader); + importPacketComponents(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(13019) + void verifyPacketComponents() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(7, packetAssets)) + .isEqualToIgnoringWhitespace(""" + { + 10630=HsHostingAsset(MANAGED_WEBSPACE, hsh00, HA hsh00, MANAGED_SERVER:vm1050, D-1000000:hsh default project:BI hsh00), + 10968=HsHostingAsset(MANAGED_SERVER, vm1061, HA vm1061, D-1015200:rar default project:BI vm1061), + 10978=HsHostingAsset(MANAGED_SERVER, vm1050, HA vm1050, D-1000000:hsh default project:BI vm1050), + 11061=HsHostingAsset(MANAGED_SERVER, vm1068, HA vm1068, D-1000300:mim default project:BI vm1068), + 11094=HsHostingAsset(MANAGED_WEBSPACE, lug00, HA lug00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI lug00), + 11111=HsHostingAsset(MANAGED_WEBSPACE, xyz68, HA xyz68, MANAGED_SERVER:vm1068, D-1000000:vm1068 Monitor:BI xyz68), + 11112=HsHostingAsset(MANAGED_WEBSPACE, mim00, HA mim00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI mim00) + } + """); + assertThat(firstOfEachType( + 5, + HsBookingItemType.CLOUD_SERVER, + HsBookingItemType.MANAGED_SERVER, + HsBookingItemType.MANAGED_WEBSPACE)) + .isEqualToIgnoringWhitespace(""" + { + 10630=HsBookingItem(MANAGED_WEBSPACE, BI hsh00, D-1000000:hsh default project, [2001-06-01,), {"HDD": 10, "Multi": 25, "SLA-Platform": "EXT24H", "SSD": 16, "Traffic": 50}), + 10968=HsBookingItem(MANAGED_SERVER, BI vm1061, D-1015200:rar default project, [2013-04-01,), {"CPU": 6, "HDD": 250, "RAM": 14, "SLA-EMail": true, "SLA-Maria": true, "SLA-Office": true, "SLA-PgSQL": true, "SLA-Platform": "EXT4H", "SLA-Web": true, "SSD": 375, "Traffic": 250}), + 10978=HsBookingItem(MANAGED_SERVER, BI vm1050, D-1000000:hsh default project, [2013-04-01,), {"CPU": 4, "HDD": 250, "RAM": 32, "SLA-EMail": true, "SLA-Maria": true, "SLA-Office": true, "SLA-PgSQL": true, "SLA-Platform": "EXT4H", "SLA-Web": true, "SSD": 150, "Traffic": 250}), + 11061=HsBookingItem(MANAGED_SERVER, BI vm1068, D-1000300:mim default project, [2013-08-19,), {"CPU": 2, "HDD": 250, "RAM": 4, "SLA-EMail": true, "SLA-Maria": true, "SLA-Office": true, "SLA-PgSQL": true, "SLA-Platform": "EXT2H", "SLA-Web": true, "Traffic": 250}), + 11094=HsBookingItem(MANAGED_WEBSPACE, BI lug00, D-1000300:mim default project, [2013-09-10,), {"Multi": 5, "SLA-Platform": "EXT24H", "SSD": 1, "Traffic": 10}), + 11111=HsBookingItem(MANAGED_WEBSPACE, BI xyz68, D-1000000:vm1068 Monitor, [2013-08-19,), {"SSD": 3}), + 11112=HsBookingItem(MANAGED_WEBSPACE, BI mim00, D-1000300:mim default project, [2013-09-17,), {"Multi": 5, "SLA-Platform": "EXT24H", "SSD": 3, "Traffic": 20}), + 11447=HsBookingItem(MANAGED_SERVER, BI vm1093, D-1000000:hsh default project, [2014-11-28,), {"CPU": 6, "HDD": 500, "RAM": 16, "SLA-EMail": true, "SLA-Maria": true, "SLA-Office": true, "SLA-PgSQL": true, "SLA-Platform": "EXT4H", "SLA-Web": true, "SSD": 300, "Traffic": 250}), + 19959=HsBookingItem(MANAGED_WEBSPACE, BI dph00, D-1101900:dph default project, [2021-06-02,), {"Multi": 1, "SLA-Platform": "EXT24H", "SSD": 25, "Traffic": 20}), + 23611=HsBookingItem(CLOUD_SERVER, BI vm2097, D-1101800:wws default project, [2022-08-10,), {"CPU": 8, "RAM": 12, "SLA-Infrastructure": "EXT4H", "SSD": 25, "Traffic": 250}) + } + """); + } + + @Test + @Order(14010) + void importUnixUsers() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/unixuser.csv")) { + final var lines = readAllLines(reader); + importUnixUsers(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(14019) + void verifyUnixUsers() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(15, unixUserAssets)).isEqualToIgnoringWhitespace(""" + { + 5803=HsHostingAsset(UNIX_USER, lug00, LUGs, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 102090}), + 5805=HsHostingAsset(UNIX_USER, lug00-wla.1, Paul Klemm, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 102091}), + 5809=HsHostingAsset(UNIX_USER, lug00-wla.2, Walter Müller, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 8, "SSD soft quota": 4, "locked": false, "shell": "/bin/bash", "userid": 102093}), + 5811=HsHostingAsset(UNIX_USER, lug00-ola.a, LUG OLA - POP a, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/usr/bin/passwd", "userid": 102094}), + 5813=HsHostingAsset(UNIX_USER, lug00-ola.b, LUG OLA - POP b, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/usr/bin/passwd", "userid": 102095}), + 5835=HsHostingAsset(UNIX_USER, lug00-test, Test, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 1024, "SSD soft quota": 1024, "locked": false, "shell": "/usr/bin/passwd", "userid": 102106}), + 5961=HsHostingAsset(UNIX_USER, xyz68, Monitoring h68, MANAGED_WEBSPACE:xyz68, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 102141}), + 5964=HsHostingAsset(UNIX_USER, mim00, Michael Mellis, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 102147}), + 5966=HsHostingAsset(UNIX_USER, mim00-1981, Jahrgangstreffen 1981, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 256, "SSD soft quota": 128, "locked": false, "shell": "/bin/bash", "userid": 102148}), + 5990=HsHostingAsset(UNIX_USER, mim00-mail, Mailbox, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 102160}), + 6705=HsHostingAsset(UNIX_USER, hsh00-mim, Michael Mellis, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/false", "userid": 10003}), + 6824=HsHostingAsset(UNIX_USER, hsh00, Hostsharing Paket, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 10000}), + 7846=HsHostingAsset(UNIX_USER, hsh00-dph, hsh00-uph, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/false", "userid": 110568}), + 9546=HsHostingAsset(UNIX_USER, dph00, Reinhard Wiese, MANAGED_WEBSPACE:dph00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 110593}), + 9596=HsHostingAsset(UNIX_USER, dph00-dph, Domain admin, MANAGED_WEBSPACE:dph00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "shell": "/bin/bash", "userid": 110594}) + } + """); + + // now with groupids + assertThat(firstOfEach(5, packetAssets, MANAGED_WEBSPACE)) + .isEqualToIgnoringWhitespace(""" + { + 10630=HsHostingAsset(MANAGED_WEBSPACE, hsh00, HA hsh00, MANAGED_SERVER:vm1050, D-1000000:hsh default project:BI hsh00, {"groupid": 6824}), + 11094=HsHostingAsset(MANAGED_WEBSPACE, lug00, HA lug00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI lug00, {"groupid": 5803}), + 11111=HsHostingAsset(MANAGED_WEBSPACE, xyz68, HA xyz68, MANAGED_SERVER:vm1068, D-1000000:vm1068 Monitor:BI xyz68, {"groupid": 5961}), + 11112=HsHostingAsset(MANAGED_WEBSPACE, mim00, HA mim00, MANAGED_SERVER:vm1068, D-1000300:mim default project:BI mim00, {"groupid": 5964}), + 19959=HsHostingAsset(MANAGED_WEBSPACE, dph00, HA dph00, MANAGED_SERVER:vm1093, D-1101900:dph default project:BI dph00, {"groupid": 9546}) + } + """); + } + + @Test + @Order(14020) + void importEmailAliases() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/emailalias.csv")) { + final var lines = readAllLines(reader); + importEmailAliases(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(14029) + void verifyEmailAliases() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(15, emailAliasAssets)).isEqualToIgnoringWhitespace(""" + { + 2403=HsHostingAsset(EMAIL_ALIAS, lug00, lug00, MANAGED_WEBSPACE:lug00, {"target": [ "michael.mellis@example.com" ]}), + 2405=HsHostingAsset(EMAIL_ALIAS, lug00-wla-listar, lug00-wla-listar, MANAGED_WEBSPACE:lug00, {"target": [ "|/home/pacs/lug00/users/in/mailinglist/listar" ]}), + 2429=HsHostingAsset(EMAIL_ALIAS, mim00, mim00, MANAGED_WEBSPACE:mim00, {"target": [ "mim12-mi@mim12.hostsharing.net" ]}), + 2431=HsHostingAsset(EMAIL_ALIAS, mim00-abruf, mim00-abruf, MANAGED_WEBSPACE:mim00, {"target": [ "michael.mellis@hostsharing.net" ]}), + 2449=HsHostingAsset(EMAIL_ALIAS, mim00-hhfx, mim00-hhfx, MANAGED_WEBSPACE:mim00, {"target": [ "mim00-hhfx", "|/usr/bin/formail -I 'Reply-To: hamburger-fx@example.net' | /usr/lib/sendmail mim00-hhfx-l" ]}), + 2451=HsHostingAsset(EMAIL_ALIAS, mim00-hhfx-l, mim00-hhfx-l, MANAGED_WEBSPACE:mim00, {"target": [ ":include:/home/pacs/mim00/etc/hhfx.list" ]}), + 2454=HsHostingAsset(EMAIL_ALIAS, mim00-dev.null, mim00-dev.null, MANAGED_WEBSPACE:mim00, {"target": [ "/dev/null" ]}), + 2455=HsHostingAsset(EMAIL_ALIAS, mim00-1_with_space, mim00-1_with_space, MANAGED_WEBSPACE:mim00, {"target": [ "|/home/pacs/mim00/install/corpslistar/listar" ]}), + 2456=HsHostingAsset(EMAIL_ALIAS, mim00-1_with_single_quotes, mim00-1_with_single_quotes, MANAGED_WEBSPACE:mim00, {"target": [ "|/home/pacs/rir00/mailinglist/ecartis -r kybs06-intern" ]}) + } + """); + } + + @Test + @Order(15000) + void createDatabaseInstances() { + createDatabaseInstances(packetAssets.values().stream().filter(ha -> ha.getType() == MANAGED_SERVER).toList()); + } + + @Test + @Order(15009) + void verifyDatabaseInstances() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(8, dbInstanceAssets)).isEqualToIgnoringWhitespace(""" + { + 0=HsHostingAsset(PGSQL_INSTANCE, vm1061|PgSql.default, vm1061-PostgreSQL default instance, MANAGED_SERVER:vm1061), + 1=HsHostingAsset(MARIADB_INSTANCE, vm1061|MariaDB.default, vm1061-MariaDB default instance, MANAGED_SERVER:vm1061), + 2=HsHostingAsset(PGSQL_INSTANCE, vm1050|PgSql.default, vm1050-PostgreSQL default instance, MANAGED_SERVER:vm1050), + 3=HsHostingAsset(MARIADB_INSTANCE, vm1050|MariaDB.default, vm1050-MariaDB default instance, MANAGED_SERVER:vm1050), + 4=HsHostingAsset(PGSQL_INSTANCE, vm1068|PgSql.default, vm1068-PostgreSQL default instance, MANAGED_SERVER:vm1068), + 5=HsHostingAsset(MARIADB_INSTANCE, vm1068|MariaDB.default, vm1068-MariaDB default instance, MANAGED_SERVER:vm1068), + 6=HsHostingAsset(PGSQL_INSTANCE, vm1093|PgSql.default, vm1093-PostgreSQL default instance, MANAGED_SERVER:vm1093), + 7=HsHostingAsset(MARIADB_INSTANCE, vm1093|MariaDB.default, vm1093-MariaDB default instance, MANAGED_SERVER:vm1093) + } + """); + } + + @Test + @Order(15010) + void importDatabaseUsers() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/database_user.csv")) { + final var lines = readAllLines(reader); + importDatabaseUsers(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(15019) + void verifyDatabaseUsers() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(10, dbUserAssets)).isEqualToIgnoringWhitespace(""" + { + 1857=HsHostingAsset(PGSQL_USER, PGU|hsh00, hsh00, MANAGED_WEBSPACE:hsh00, PGSQL_INSTANCE:vm1050|PgSql.default, { "password": "SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$JDiZmaxU+O+ByArLY/CkYZ8HbOk0r/I8LyABnno5gQs=:NI3T500/63dzI1B07Jh3UtQGlukS6JxuS0XoxM/QgAc="}), + 1858=HsHostingAsset(MARIADB_USER, MAU|hsh00, hsh00, MANAGED_WEBSPACE:hsh00, MARIADB_INSTANCE:vm1050|MariaDB.default, { "password": "*59067A36BA197AD0A47D74909296C5B002A0FB9F"}), + 1859=HsHostingAsset(PGSQL_USER, PGU|hsh00_vorstand, hsh00_vorstand, MANAGED_WEBSPACE:hsh00, PGSQL_INSTANCE:vm1050|PgSql.default, { "password": "SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$54Wh+OGx/GaIvAia+I3k78jHGhqmYwe4+iLssmH5zhk=:D4Gq1z2Li2BVSaZrz1azDrs6pwsIzhq4+suK1Hh6ZIg="}), + 1860=HsHostingAsset(PGSQL_USER, PGU|hsh00_hsadmin, hsh00_hsadmin, MANAGED_WEBSPACE:hsh00, PGSQL_INSTANCE:vm1050|PgSql.default, { "password": "SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$54Wh+OGx/GaIvAia+I3k78jHGhqmYwe4+iLssmH5zhk=:D4Gq1z2Li2BVSaZrz1azDrs6pwsIzhq4+suK1Hh6ZIg="}), + 1861=HsHostingAsset(PGSQL_USER, PGU|hsh00_hsadmin_ro, hsh00_hsadmin_ro, MANAGED_WEBSPACE:hsh00, PGSQL_INSTANCE:vm1050|PgSql.default, { "password": "SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$UhJnJJhmKANbcaG+izWK3rz5bmhhluSuiCJFlUmDVI8=:6AC4mbLfJGiGlEOWhpz9BivvMODhLLHOnRnnktJPgn8="}), + 4908=HsHostingAsset(MARIADB_USER, MAU|hsh00_mantis, hsh00_mantis, MANAGED_WEBSPACE:hsh00, MARIADB_INSTANCE:vm1050|MariaDB.default, { "password": "*EA4C0889A22AAE66BBEBC88161E8CF862D73B44F"}), + 4909=HsHostingAsset(MARIADB_USER, MAU|hsh00_mantis_ro, hsh00_mantis_ro, MANAGED_WEBSPACE:hsh00, MARIADB_INSTANCE:vm1050|MariaDB.default, { "password": "*B3BB6D0DA2EC01958616E9B3BCD2926FE8C38383"}), + 4931=HsHostingAsset(PGSQL_USER, PGU|hsh00_phpPgSqlAdmin, hsh00_phpPgSqlAdmin, MANAGED_WEBSPACE:hsh00, PGSQL_INSTANCE:vm1050|PgSql.default, { "password": "SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$UhJnJJhmKANbcaG+izWK3rz5bmhhluSuiCJFlUmDVI8=:6AC4mbLfJGiGlEOWhpz9BivvMODhLLHOnRnnktJPgn8="}), + 4932=HsHostingAsset(MARIADB_USER, MAU|hsh00_phpMyAdmin, hsh00_phpMyAdmin, MANAGED_WEBSPACE:hsh00, MARIADB_INSTANCE:vm1050|MariaDB.default, { "password": "*3188720B1889EF5447C722629765F296F40257C2"}), + 7520=HsHostingAsset(MARIADB_USER, MAU|lug00_wla, lug00_wla, MANAGED_WEBSPACE:lug00, MARIADB_INSTANCE:vm1068|MariaDB.default, { "password": "*11667C0EAC42BF8B0295ABEDC7D2868A835E4DB5"}) + } + """); + } + + @Test + @Order(15020) + void importDatabases() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/database.csv")) { + final var lines = readAllLines(reader); + importDatabases(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(15029) + void verifyDatabases() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(10, dbAssets)).isEqualToIgnoringWhitespace(""" + { + 1077=HsHostingAsset(PGSQL_DATABASE, PGD|hsh00_vorstand, hsh00_vorstand, PGSQL_USER:PGU|hsh00_vorstand, {"encoding": "LATIN1"}), + 1786=HsHostingAsset(MARIADB_DATABASE, MAD|hsh00_addr, hsh00_addr, MARIADB_USER:MAU|hsh00, {"encoding": "latin1"}), + 1805=HsHostingAsset(MARIADB_DATABASE, MAD|hsh00_dba, hsh00_dba, MARIADB_USER:MAU|hsh00, {"encoding": "latin1"}), + 1858=HsHostingAsset(PGSQL_DATABASE, PGD|hsh00, hsh00, PGSQL_USER:PGU|hsh00, {"encoding": "LATIN1"}), + 1860=HsHostingAsset(PGSQL_DATABASE, PGD|hsh00_hsadmin, hsh00_hsadmin, PGSQL_USER:PGU|hsh00_hsadmin, {"encoding": "UTF8"}), + 4908=HsHostingAsset(MARIADB_DATABASE, MAD|hsh00_mantis, hsh00_mantis, MARIADB_USER:MAU|hsh00_mantis, {"encoding": "utf8"}), + 4931=HsHostingAsset(PGSQL_DATABASE, PGD|hsh00_phpPgSqlAdmin, hsh00_phpPgSqlAdmin, PGSQL_USER:PGU|hsh00_phpPgSqlAdmin, {"encoding": "UTF8"}), + 4932=HsHostingAsset(PGSQL_DATABASE, PGD|hsh00_phpPgSqlAdmin_new, hsh00_phpPgSqlAdmin_new, PGSQL_USER:PGU|hsh00_phpPgSqlAdmin, {"encoding": "UTF8"}), + 4941=HsHostingAsset(MARIADB_DATABASE, MAD|hsh00_phpMyAdmin, hsh00_phpMyAdmin, MARIADB_USER:MAU|hsh00_phpMyAdmin, {"encoding": "utf8"}), + 4942=HsHostingAsset(MARIADB_DATABASE, MAD|hsh00_phpMyAdmin_old, hsh00_phpMyAdmin_old, MARIADB_USER:MAU|hsh00_phpMyAdmin, {"encoding": "utf8"}) + } + """); + } + + @Test + @Order(16010) + void importDomains() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/domain.csv")) { + final var lines = readAllLines(reader); + importDomains(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(16020) + void importZonenfiles() { + final var reflections = new Reflections(MIGRATION_DATA_PATH + "/hosting/zonefiles", new ResourcesScanner()); + final var zonefileFiles = reflections.getResources(Pattern.compile(".*\\.json")).stream().sorted().toList(); + zonefileFiles.forEach(zonenfileName -> { + System.out.println("Processing zonenfile: " + zonenfileName); + importZonefiles(vmName(zonenfileName), resourceAsString(zonenfileName)); + }); + } + + @Test + @Order(16029) + void verifyDomains() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(12, domainSetupAssets)).isEqualToIgnoringWhitespace(""" + { + 4531=HsHostingAsset(DOMAIN_SETUP, l-u-g.org, l-u-g.org, D-1000300:mim default project:BI l-u-g.org), + 4532=HsHostingAsset(DOMAIN_SETUP, linuxfanboysngirls.de, linuxfanboysngirls.de, D-1000300:mim default project:BI linuxfanboysngirls.de), + 4534=HsHostingAsset(DOMAIN_SETUP, lug-mars.de, lug-mars.de, D-1000300:mim default project:BI lug-mars.de), + 4581=HsHostingAsset(DOMAIN_SETUP, 1981.ist-im-netz.de, 1981.ist-im-netz.de, DOMAIN_SETUP:ist-im-netz.de), + 4587=HsHostingAsset(DOMAIN_SETUP, mellis.de, mellis.de, D-1000300:mim default project:BI mellis.de), + 4589=HsHostingAsset(DOMAIN_SETUP, ist-im-netz.de, ist-im-netz.de, D-1000300:mim default project:BI ist-im-netz.de), + 4600=HsHostingAsset(DOMAIN_SETUP, waera.de, waera.de, D-1000300:mim default project:BI waera.de), + 4604=HsHostingAsset(DOMAIN_SETUP, xn--wra-qla.de, wära.de, D-1000300:mim default project:BI xn--wra-qla.de), + 7662=HsHostingAsset(DOMAIN_SETUP, dph-netzwerk.de, dph-netzwerk.de, D-1101900:dph default project:BI dph-netzwerk.de) + } + """); + + assertThat(firstOfEach(12, domainDnsSetupAssets)).isEqualToIgnoringWhitespace(""" + { + 4531=HsHostingAsset(DOMAIN_DNS_SETUP, l-u-g.org|DNS, DNS-Setup für l-u-g.org, DOMAIN_SETUP:l-u-g.org, MANAGED_WEBSPACE:lug00, {"TTL": 21600, "auto-A-RR": true, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": true, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 4532=HsHostingAsset(DOMAIN_DNS_SETUP, linuxfanboysngirls.de|DNS, DNS-Setup für linuxfanboysngirls.de, DOMAIN_SETUP:linuxfanboysngirls.de, MANAGED_WEBSPACE:lug00, {"TTL": 21600, "auto-A-RR": true, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": true, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 4534=HsHostingAsset(DOMAIN_DNS_SETUP, lug-mars.de|DNS, DNS-Setup für lug-mars.de, DOMAIN_SETUP:lug-mars.de, MANAGED_WEBSPACE:lug00, {"TTL": 14400, "auto-A-RR": true, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": false, "auto-NS-RR": true, "auto-SOA": false, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": false, "auto-WILDCARD-SPF-RR": false, "user-RR": [ "lug-mars.de. 14400 IN SOA dns1.hostsharing.net. hostmaster.hostsharing.net. 1611590905 10800 3600 604800 3600", "lug-mars.de. 14400 IN MX 10 mailin1.hostsharing.net.", "lug-mars.de. 14400 IN MX 20 mailin2.hostsharing.net.", "lug-mars.de. 14400 IN MX 30 mailin3.hostsharing.net.", "bbb.lug-mars.de. 14400 IN A 83.223.79.72", "ftp.lug-mars.de. 14400 IN A 83.223.79.72", "www.lug-mars.de. 14400 IN A 83.223.79.72" ]}), + 4581=HsHostingAsset(DOMAIN_DNS_SETUP, 1981.ist-im-netz.de|DNS, DNS-Setup für 1981.ist-im-netz.de, DOMAIN_SETUP:1981.ist-im-netz.de, MANAGED_WEBSPACE:mim00, {"TTL": 21600, "auto-A-RR": true, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": true, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 4587=HsHostingAsset(DOMAIN_DNS_SETUP, mellis.de|DNS, DNS-Setup für mellis.de, DOMAIN_SETUP:mellis.de, MANAGED_WEBSPACE:mim00, {"TTL": 21600, "auto-A-RR": true, "auto-AAAA-RR": true, "auto-AUTOCONFIG-RR": true, "auto-AUTODISCOVER-RR": true, "auto-DKIM-RR": true, "auto-MAILSERVICES-RR": true, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": true, "auto-WILDCARD-MX-RR": true, "auto-WILDCARD-SPF-RR": true, "user-RR": [ "dump.hoennig.de. 21600 IN CNAME mih12.hostsharing.net.", "fotos.hoennig.de. 21600 IN CNAME mih12.hostsharing.net.", "maven.hoennig.de. 21600 IN NS dns1.hostsharing.net." ]}), + 4589=HsHostingAsset(DOMAIN_DNS_SETUP, ist-im-netz.de|DNS, DNS-Setup für ist-im-netz.de, DOMAIN_SETUP:ist-im-netz.de, MANAGED_WEBSPACE:mim00, {"TTL": 700, "auto-A-RR": true, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": false, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 4600=HsHostingAsset(DOMAIN_DNS_SETUP, waera.de|DNS, DNS-Setup für waera.de, DOMAIN_SETUP:waera.de, MANAGED_WEBSPACE:mim00, {"TTL": 21600, "auto-A-RR": false, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": false, "auto-NS-RR": false, "auto-SOA": false, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": false, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": false, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 4604=HsHostingAsset(DOMAIN_DNS_SETUP, xn--wra-qla.de|DNS, DNS-Setup für wära.de, DOMAIN_SETUP:xn--wra-qla.de, MANAGED_WEBSPACE:mim00, {"TTL": 21600, "auto-A-RR": false, "auto-AAAA-RR": false, "auto-AUTOCONFIG-RR": false, "auto-AUTODISCOVER-RR": false, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": false, "auto-MX-RR": false, "auto-NS-RR": false, "auto-SOA": false, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": false, "auto-WILDCARD-AAAA-RR": false, "auto-WILDCARD-MX-RR": false, "auto-WILDCARD-SPF-RR": false, "user-RR": [ ]}), + 7662=HsHostingAsset(DOMAIN_DNS_SETUP, dph-netzwerk.de|DNS, DNS-Setup für dph-netzwerk.de, DOMAIN_SETUP:dph-netzwerk.de, MANAGED_WEBSPACE:dph00, {"TTL": 21600, "auto-A-RR": true, "auto-AAAA-RR": true, "auto-AUTOCONFIG-RR": true, "auto-AUTODISCOVER-RR": true, "auto-DKIM-RR": false, "auto-MAILSERVICES-RR": true, "auto-MX-RR": true, "auto-NS-RR": true, "auto-SOA": true, "auto-SPF-RR": false, "auto-WILDCARD-A-RR": true, "auto-WILDCARD-AAAA-RR": true, "auto-WILDCARD-MX-RR": true, "auto-WILDCARD-SPF-RR": false, "user-RR": [ "dph-netzwerk.de. 21600 IN TXT \\"v=spf1 include:spf.hostsharing.net ?all\\"", "*.dph-netzwerk.de. 21600 IN TXT \\"v=spf1 include:spf.hostsharing.net ?all\\"" ]}) + } + """); + + assertThat(firstOfEach(12, domainHttpSetupAssets)).isEqualToIgnoringWhitespace(""" + { + 4531=HsHostingAsset(DOMAIN_HTTP_SETUP, l-u-g.org|HTTP, HTTP-Setup für l-u-g.org, DOMAIN_SETUP:l-u-g.org, UNIX_USER:lug00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": false, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 4532=HsHostingAsset(DOMAIN_HTTP_SETUP, linuxfanboysngirls.de|HTTP, HTTP-Setup für linuxfanboysngirls.de, DOMAIN_SETUP:linuxfanboysngirls.de, UNIX_USER:lug00-wla.2, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": false, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 4534=HsHostingAsset(DOMAIN_HTTP_SETUP, lug-mars.de|HTTP, HTTP-Setup für lug-mars.de, DOMAIN_SETUP:lug-mars.de, UNIX_USER:lug00-wla.2, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": true, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "www" ]}), + 4581=HsHostingAsset(DOMAIN_HTTP_SETUP, 1981.ist-im-netz.de|HTTP, HTTP-Setup für 1981.ist-im-netz.de, DOMAIN_SETUP:1981.ist-im-netz.de, UNIX_USER:mim00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": false, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 4587=HsHostingAsset(DOMAIN_HTTP_SETUP, mellis.de|HTTP, HTTP-Setup für mellis.de, DOMAIN_SETUP:mellis.de, UNIX_USER:mim00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": false, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": true, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "www", "michael", "test", "photos", "static", "input" ]}), + 4589=HsHostingAsset(DOMAIN_HTTP_SETUP, ist-im-netz.de|HTTP, HTTP-Setup für ist-im-netz.de, DOMAIN_SETUP:ist-im-netz.de, UNIX_USER:mim00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": false, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": true, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 4600=HsHostingAsset(DOMAIN_HTTP_SETUP, waera.de|HTTP, HTTP-Setup für waera.de, DOMAIN_SETUP:waera.de, UNIX_USER:mim00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": false, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 4604=HsHostingAsset(DOMAIN_HTTP_SETUP, xn--wra-qla.de|HTTP, HTTP-Setup für wära.de, DOMAIN_SETUP:xn--wra-qla.de, UNIX_USER:mim00, {"autoconfig": false, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": false, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}), + 7662=HsHostingAsset(DOMAIN_HTTP_SETUP, dph-netzwerk.de|HTTP, HTTP-Setup für dph-netzwerk.de, DOMAIN_SETUP:dph-netzwerk.de, UNIX_USER:dph00-dph, {"autoconfig": true, "cgi": true, "fastcgi": true, "fcgi-php-bin": "/usr/lib/cgi-bin/php", "greylisting": true, "htdocsfallback": true, "includes": true, "indexes": true, "letsencrypt": true, "multiviews": true, "passenger": true, "passenger-errorpage": false, "passenger-nodejs": "/usr/bin/node", "passenger-python": "/usr/bin/python3", "passenger-ruby": "/usr/bin/ruby", "subdomains": [ "*" ]}) + } + """); + + assertThat(firstOfEach(12, domainMBoxSetupAssets)).isEqualToIgnoringWhitespace(""" + { + 4531=HsHostingAsset(DOMAIN_MBOX_SETUP, l-u-g.org|MBOX, E-Mail-Empfang-Setup für l-u-g.org, DOMAIN_SETUP:l-u-g.org, MANAGED_WEBSPACE:lug00), + 4532=HsHostingAsset(DOMAIN_MBOX_SETUP, linuxfanboysngirls.de|MBOX, E-Mail-Empfang-Setup für linuxfanboysngirls.de, DOMAIN_SETUP:linuxfanboysngirls.de, MANAGED_WEBSPACE:lug00), + 4534=HsHostingAsset(DOMAIN_MBOX_SETUP, lug-mars.de|MBOX, E-Mail-Empfang-Setup für lug-mars.de, DOMAIN_SETUP:lug-mars.de, MANAGED_WEBSPACE:lug00), + 4581=HsHostingAsset(DOMAIN_MBOX_SETUP, 1981.ist-im-netz.de|MBOX, E-Mail-Empfang-Setup für 1981.ist-im-netz.de, DOMAIN_SETUP:1981.ist-im-netz.de, MANAGED_WEBSPACE:mim00), + 4587=HsHostingAsset(DOMAIN_MBOX_SETUP, mellis.de|MBOX, E-Mail-Empfang-Setup für mellis.de, DOMAIN_SETUP:mellis.de, MANAGED_WEBSPACE:mim00), + 4589=HsHostingAsset(DOMAIN_MBOX_SETUP, ist-im-netz.de|MBOX, E-Mail-Empfang-Setup für ist-im-netz.de, DOMAIN_SETUP:ist-im-netz.de, MANAGED_WEBSPACE:mim00), + 4600=HsHostingAsset(DOMAIN_MBOX_SETUP, waera.de|MBOX, E-Mail-Empfang-Setup für waera.de, DOMAIN_SETUP:waera.de, MANAGED_WEBSPACE:mim00), + 4604=HsHostingAsset(DOMAIN_MBOX_SETUP, xn--wra-qla.de|MBOX, E-Mail-Empfang-Setup für wära.de, DOMAIN_SETUP:xn--wra-qla.de, MANAGED_WEBSPACE:mim00), + 7662=HsHostingAsset(DOMAIN_MBOX_SETUP, dph-netzwerk.de|MBOX, E-Mail-Empfang-Setup für dph-netzwerk.de, DOMAIN_SETUP:dph-netzwerk.de, MANAGED_WEBSPACE:dph00) + } + """); + + assertThat(firstOfEach(12, domainSmtpSetupAssets)).isEqualToIgnoringWhitespace(""" + { + 4531=HsHostingAsset(DOMAIN_SMTP_SETUP, l-u-g.org|SMTP, E-Mail-Versand-Setup für l-u-g.org, DOMAIN_SETUP:l-u-g.org, MANAGED_WEBSPACE:lug00), + 4532=HsHostingAsset(DOMAIN_SMTP_SETUP, linuxfanboysngirls.de|SMTP, E-Mail-Versand-Setup für linuxfanboysngirls.de, DOMAIN_SETUP:linuxfanboysngirls.de, MANAGED_WEBSPACE:lug00), + 4534=HsHostingAsset(DOMAIN_SMTP_SETUP, lug-mars.de|SMTP, E-Mail-Versand-Setup für lug-mars.de, DOMAIN_SETUP:lug-mars.de, MANAGED_WEBSPACE:lug00), + 4581=HsHostingAsset(DOMAIN_SMTP_SETUP, 1981.ist-im-netz.de|SMTP, E-Mail-Versand-Setup für 1981.ist-im-netz.de, DOMAIN_SETUP:1981.ist-im-netz.de, MANAGED_WEBSPACE:mim00), + 4587=HsHostingAsset(DOMAIN_SMTP_SETUP, mellis.de|SMTP, E-Mail-Versand-Setup für mellis.de, DOMAIN_SETUP:mellis.de, MANAGED_WEBSPACE:mim00), + 4589=HsHostingAsset(DOMAIN_SMTP_SETUP, ist-im-netz.de|SMTP, E-Mail-Versand-Setup für ist-im-netz.de, DOMAIN_SETUP:ist-im-netz.de, MANAGED_WEBSPACE:mim00), + 4600=HsHostingAsset(DOMAIN_SMTP_SETUP, waera.de|SMTP, E-Mail-Versand-Setup für waera.de, DOMAIN_SETUP:waera.de, MANAGED_WEBSPACE:mim00), + 4604=HsHostingAsset(DOMAIN_SMTP_SETUP, xn--wra-qla.de|SMTP, E-Mail-Versand-Setup für wära.de, DOMAIN_SETUP:xn--wra-qla.de, MANAGED_WEBSPACE:mim00), + 7662=HsHostingAsset(DOMAIN_SMTP_SETUP, dph-netzwerk.de|SMTP, E-Mail-Versand-Setup für dph-netzwerk.de, DOMAIN_SETUP:dph-netzwerk.de, MANAGED_WEBSPACE:dph00) + } + """); + } + + @Test + @Order(17010) + void importEmailAddresses() { + try (Reader reader = resourceReader(MIGRATION_DATA_PATH + "/hosting/emailaddr.csv")) { + final var lines = readAllLines(reader); + importEmailAddresses(justHeader(lines), withoutHeader(lines)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(17029) + void verifyEmailAddresses() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(12, emailAddressAssets)).isEqualToIgnoringWhitespace(""" + { + 54745=HsHostingAsset(EMAIL_ADDRESS, lugmaster@l-u-g.org, lugmaster@l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "lugmaster", "target": [ "nobody" ]}), + 54746=HsHostingAsset(EMAIL_ADDRESS, abuse@l-u-g.org, abuse@l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "abuse", "target": [ "lug00" ]}), + 54747=HsHostingAsset(EMAIL_ADDRESS, postmaster@l-u-g.org, postmaster@l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "postmaster", "target": [ "nobody" ]}), + 54748=HsHostingAsset(EMAIL_ADDRESS, webmaster@l-u-g.org, webmaster@l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "webmaster", "target": [ "nobody" ]}), + 54749=HsHostingAsset(EMAIL_ADDRESS, abuse@linuxfanboysngirls.de, abuse@linuxfanboysngirls.de, DOMAIN_MBOX_SETUP:linuxfanboysngirls.de|MBOX, {"local-part": "abuse", "target": [ "lug00-mars" ]}), + 54750=HsHostingAsset(EMAIL_ADDRESS, postmaster@linuxfanboysngirls.de, postmaster@linuxfanboysngirls.de, DOMAIN_MBOX_SETUP:linuxfanboysngirls.de|MBOX, {"local-part": "postmaster", "target": [ "m.hinsel@example.org" ]}), + 54751=HsHostingAsset(EMAIL_ADDRESS, webmaster@linuxfanboysngirls.de, webmaster@linuxfanboysngirls.de, DOMAIN_MBOX_SETUP:linuxfanboysngirls.de|MBOX, {"local-part": "webmaster", "target": [ "m.hinsel@example.org" ]}), + 54755=HsHostingAsset(EMAIL_ADDRESS, abuse@lug-mars.de, abuse@lug-mars.de, DOMAIN_MBOX_SETUP:lug-mars.de|MBOX, {"local-part": "abuse", "target": [ "lug00-marl" ]}), + 54756=HsHostingAsset(EMAIL_ADDRESS, postmaster@lug-mars.de, postmaster@lug-mars.de, DOMAIN_MBOX_SETUP:lug-mars.de|MBOX, {"local-part": "postmaster", "target": [ "m.hinsel@example.org" ]}), + 54757=HsHostingAsset(EMAIL_ADDRESS, webmaster@lug-mars.de, webmaster@lug-mars.de, DOMAIN_MBOX_SETUP:lug-mars.de|MBOX, {"local-part": "webmaster", "target": [ "m.hinsel@example.org" ]}), + 54760=HsHostingAsset(EMAIL_ADDRESS, info@hamburg-west.l-u-g.org, info@hamburg-west.l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "info", "sub-domain": "hamburg-west", "target": [ "peter.lottmann@example.com" ]}), + 54761=HsHostingAsset(EMAIL_ADDRESS, lugmaster@hamburg-west.l-u-g.org, lugmaster@hamburg-west.l-u-g.org, DOMAIN_MBOX_SETUP:l-u-g.org|MBOX, {"local-part": "lugmaster", "sub-domain": "hamburg-west", "target": [ "raoul.lottmann@example.com" ]}) + } + """); + } + + // -------------------------------------------------------------------------------------------- + + @Test + @Order(18010) + void validateBookingItems() { + bookingItems.forEach((id, bi) -> { + try { + HsBookingItemEntityValidatorRegistry.validated(em, bi); + } catch (final Exception exc) { + errors.add("validation failed for id:" + id + "( " + bi + "): " + exc.getMessage()); + } + }); + } + + @Test + @Order(18020) + void validateIpNumberAssets() { + validateHostingAssets(ipNumberAssets); + } + + @Test + @Order(18021) + void validateServerAndWebspaceAssets() { + validateHostingAssets(packetAssets); + } + + @Test + @Order(18022) + void validateUnixUserAssets() { + validateHostingAssets(unixUserAssets); + } + + @Test + @Order(18023) + void validateEmailAliasAssets() { + validateHostingAssets(emailAliasAssets); + } + + @Test + @Order(18030) + void validateDbInstanceAssets() { + validateHostingAssets(dbInstanceAssets); + } + + @Test + @Order(18031) + void validateDbUserAssets() { + validateHostingAssets(dbUserAssets); + } + + @Test + @Order(18032) + void validateDbAssets() { + validateHostingAssets(dbAssets); + } + + @Test + @Order(18040) + void validateDomainSetupAssets() { + validateHostingAssets(domainSetupAssets); + } + + @Test + @Order(18041) + void validateDomainDnsSetupAssets() { + validateHostingAssets(domainDnsSetupAssets); + } + + @Test + @Order(18042) + void validateDomainHttpSetupAssets() { + validateHostingAssets(domainHttpSetupAssets); + } + + @Test + @Order(18043) + void validateDomainSmtpSetupAssets() { + validateHostingAssets(domainSmtpSetupAssets); + } + + @Test + @Order(18044) + void validateDomainMBoxSetupAssets() { + validateHostingAssets(domainMBoxSetupAssets); + } + + @Test + @Order(18050) + void validateEmailAddressAssets() { + validateHostingAssets(emailAddressAssets); + } + + void validateHostingAssets(final Map assets) { + assets.forEach((id, ha) -> { + logError(() -> + new HostingAssetEntitySaveProcessor(em, ha) + .preprocessEntity() + .validateEntity() + .prepareForSave() + ); + }); + } + + @Test + @Order(18999) + @ContinueOnFailure + void logValidationErrors() { + if (isImportingControlledTestData()) { + expectError("zonedata dom_owner of mellis.de is old00 but expected to be mim00"); + expectError("\nexpected: \"vm1068\"\n but was: \"vm1093\""); + expectError("['EMAIL_ADDRESS:webmaster@hamburg-west.l-u-g.org.config.target' is expected to match any of [^[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$] but 'raoul.lottmann@example.com peter.lottmann@example.com' does not match any]"); + expectError("['EMAIL_ADDRESS:abuse@mellis.de.config.target' length is expected to be at min 1 but length of [[]] is 0]"); + expectError("['EMAIL_ADDRESS:abuse@ist-im-netz.de.config.target' length is expected to be at min 1 but length of [[]] is 0]"); + } + this.assertNoErrors(); + } + + // -------------------------------------------------------------------------------------------- + + @Test + @Order(19000) + @Commit + void persistBookingProjects() { + + System.out.println("PERSISTING booking-projects to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + bookingProjects.forEach(this::persist); + }).assertSuccessful(); + } + + @Test + @Order(19010) + @Commit + void persistBookingItems() { + + System.out.println("PERSISTING booking-items to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + bookingItems.forEach(this::persistRecursively); + }).assertSuccessful(); + } + + @Test + @Order(19120) + @Commit + void persistCloudServers() { + + System.out.println("PERSISTING cloud-servers to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + + persistHostingAssets(packetAssets, CLOUD_SERVER); + } + + @Test + @Order(19130) + @Commit + void persistManagedServers() { + System.out.println("PERSISTING managed-servers to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(packetAssets, MANAGED_SERVER); + } + + @Test + @Order(19140) + @Commit + void persistManagedWebspaces() { + System.out.println("PERSISTING managed-webspaces to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(packetAssets, MANAGED_WEBSPACE); + } + + @Test + @Order(19150) + @Commit + void persistIPNumbers() { + System.out.println("PERSISTING ip-numbers to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(ipNumberAssets); + } + + @Test + @Order(19160) + @Commit + void persistUnixUsers() { + System.out.println("PERSISTING unix-users to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(unixUserAssets); + } + + @Test + @Order(19170) + @Commit + void persistEmailAliases() { + System.out.println("PERSISTING email-aliases to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(emailAliasAssets); + } + + @Test + @Order(19200) + @Commit + void persistDatabaseInstances() { + System.out.println("PERSISTING db-users to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(dbInstanceAssets); + } + + @Test + @Order(19210) + @Commit + void persistDatabaseUsers() { + System.out.println("PERSISTING db-users to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(dbUserAssets); + } + + @Test + @Order(19220) + @Commit + void persistDatabases() { + System.out.println("PERSISTING databases to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(dbAssets); + } + + @Test + @Order(19300) + @Commit + void persistDomainSetups() { + System.out.println("PERSISTING domain setups to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(domainSetupAssets); + } + + @Test + @Order(19301) + @Commit + void persistDomainDnsSetups() { + System.out.println("PERSISTING domain DNS setups to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + HsDomainDnsSetupHostingAssetValidator.addZonefileErrorsTo(zonefileErrors); + persistHostingAssets(domainDnsSetupAssets); + } + + @Test + @Order(19302) + @Commit + void persistDomainHttpSetups() { + System.out.println("PERSISTING domain HTTP setups to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(domainHttpSetupAssets); + } + + @Test + @Order(19303) + @Commit + void persistDomainMboxSetups() { + System.out.println("PERSISTING domain MBOX setups to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(domainMBoxSetupAssets); + } + + @Test + @Order(19304) + @Commit + void persistDomainSmtpSetups() { + System.out.println("PERSISTING domain SMTP setups to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(domainSmtpSetupAssets); + } + + @Test + @Order(19400) + @Commit + void persistEmailAddresses() { + System.out.println("PERSISTING email-aliases to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); + persistHostingAssets(emailAddressAssets); + } + + @Test + @Order(19900) + void verifyPersistedUnixUsersWithUserId() { + assumeThatWeAreImportingControlledTestData(); + + assertThat(firstOfEach(15, unixUserAssets)).isEqualToIgnoringWhitespace(""" + { + 5803=HsHostingAsset(UNIX_USER, lug00, LUGs, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102090}), + 5805=HsHostingAsset(UNIX_USER, lug00-wla.1, Paul Klemm, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102091}), + 5809=HsHostingAsset(UNIX_USER, lug00-wla.2, Walter Müller, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 8, "SSD soft quota": 4, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102093}), + 5811=HsHostingAsset(UNIX_USER, lug00-ola.a, LUG OLA - POP a, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/usr/bin/passwd", "userid": 102094}), + 5813=HsHostingAsset(UNIX_USER, lug00-ola.b, LUG OLA - POP b, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/usr/bin/passwd", "userid": 102095}), + 5835=HsHostingAsset(UNIX_USER, lug00-test, Test, MANAGED_WEBSPACE:lug00, {"SSD hard quota": 1024, "SSD soft quota": 1024, "locked": false, "password": null, "shell": "/usr/bin/passwd", "userid": 102106}), + 5961=HsHostingAsset(UNIX_USER, xyz68, Monitoring h68, MANAGED_WEBSPACE:xyz68, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102141}), + 5964=HsHostingAsset(UNIX_USER, mim00, Michael Mellis, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102147}), + 5966=HsHostingAsset(UNIX_USER, mim00-1981, Jahrgangstreffen 1981, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 256, "SSD soft quota": 128, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102148}), + 5990=HsHostingAsset(UNIX_USER, mim00-mail, Mailbox, MANAGED_WEBSPACE:mim00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 102160}), + 6705=HsHostingAsset(UNIX_USER, hsh00-mim, Michael Mellis, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/false", "userid": 10003}), + 6824=HsHostingAsset(UNIX_USER, hsh00, Hostsharing Paket, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 10000}), + 7846=HsHostingAsset(UNIX_USER, hsh00-dph, hsh00-uph, MANAGED_WEBSPACE:hsh00, {"HDD hard quota": 0, "HDD soft quota": 0, "SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/false", "userid": 110568}), + 9546=HsHostingAsset(UNIX_USER, dph00, Reinhard Wiese, MANAGED_WEBSPACE:dph00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 110593}), + 9596=HsHostingAsset(UNIX_USER, dph00-dph, Domain admin, MANAGED_WEBSPACE:dph00, {"SSD hard quota": 0, "SSD soft quota": 0, "locked": false, "password": null, "shell": "/bin/bash", "userid": 110594}) + } + """); + } + + @Test + @Order(19910) + void verifyBookingItemsAreActuallyPersisted() { + final var biCount = (Integer) em.createNativeQuery("select count(*) from hs_booking_item", Integer.class) + .getSingleResult(); + assertThat(biCount).isGreaterThan(isImportingControlledTestData() ? 5 : 500); + } + + @Test + @Order(19920) + void verifyHostingAssetsAreActuallyPersisted() { + final var haCount = (Integer) em.createNativeQuery("select count(*) from hs_hosting_asset", Integer.class) + .getSingleResult(); + assertThat(haCount).isGreaterThan(isImportingControlledTestData() ? 40 : 15000); + + verifyActuallyPersistedHostingAssetCount(CLOUD_SERVER, 1, 50); + verifyActuallyPersistedHostingAssetCount(MANAGED_SERVER, 4, 100); + verifyActuallyPersistedHostingAssetCount(MANAGED_WEBSPACE, 5, 100); + verifyActuallyPersistedHostingAssetCount(UNIX_USER, 15, 100); + verifyActuallyPersistedHostingAssetCount(EMAIL_ALIAS, 9, 1400); + verifyActuallyPersistedHostingAssetCount(PGSQL_DATABASE, 8, 100); + verifyActuallyPersistedHostingAssetCount(MARIADB_DATABASE, 8, 100); + verifyActuallyPersistedHostingAssetCount(DOMAIN_SETUP, 9, 100); + verifyActuallyPersistedHostingAssetCount(EMAIL_ADDRESS, 71, 30000); + } + + @Test + @Order(19930) + void verifyProjectAgentsCanViewEmailAddresses() { + assumeThatWeAreImportingControlledTestData(); + + final var haCount = jpaAttempt.transacted(() -> { + context(rbacSuperuser, "hs_booking_project#D-1000300-mimdefaultproject:AGENT"); + return (Integer) em.createNativeQuery("select count(*) from hs_hosting_asset_rv where type='EMAIL_ADDRESS'", Integer.class) + .getSingleResult(); + }).assertSuccessful().returnedValue(); + assertThat(haCount).isEqualTo(68); + } + + // ============================================================================================ + + @Test + @Order(19999) + void logErrorsAfterPersistingHostingAssets() { + errors.addAll(zonefileErrors); + if (isImportingControlledTestData()) { + expectError("[waera.de|DNS] zone waera.de/IN: has 0 SOA records"); + expectError("[waera.de|DNS] zone waera.de/IN: has no NS records"); + expectError("[waera.de|DNS] zone waera.de/IN: not loaded due to errors."); + expectError("[xn--wra-qla.de|DNS] zone xn--wra-qla.de/IN: has 0 SOA records"); + expectError("[xn--wra-qla.de|DNS] zone xn--wra-qla.de/IN: has no NS records"); + expectError("[xn--wra-qla.de|DNS] zone xn--wra-qla.de/IN: not loaded due to errors."); + } + assertNoErrors(); + } + + // ============================================================================================ + + private String vmName(final String zonenfileName) { + return zonenfileName.substring(zonenfileName.length() - "vm0000.json".length()).substring(0, 6); + } + + private void persistRecursively(final Integer key, final HsBookingItem bi) { + if (bi.getParentItem() != null) { + persistRecursively(key, HsBookingItemEntityValidatorRegistry.validated(em, bi.getParentItem())); + } + persist(key, HsBookingItemEntityValidatorRegistry.validated(em, bi)); + } + + private void persistHostingAssets(final Map assets) { + persistHostingAssets(assets, null); + } + + private void persistHostingAssets(final Map assets, final HsHostingAssetType type) { + final var assetsOfType = assets.entrySet().stream() + .filter(entry -> type == null || type == entry.getValue().getType()) + .toList(); + final var chunkSize = isImportingControlledTestData() ? 10 : 500; + ListUtils.partition(assetsOfType, chunkSize).forEach(chunk -> + jpaAttempt.transacted(() -> { + context(rbacSuperuser); + chunk.forEach(entry -> + logError(() -> + new HostingAssetEntitySaveProcessor(em, entry.getValue()) + .preprocessEntity() + .validateEntityIgnoring( + "'EMAIL_ALIAS:.*\\.config\\.target' .*", + "'EMAIL_ADDRESS:.*\\.config\\.target' .*" + ) + .prepareForSave() + .saveUsing(entity -> persist(entry.getKey(), entity)) + .validateContext() + )); + } + ).assertSuccessful() + ); + } + + private void verifyActuallyPersistedHostingAssetCount( + final HsHostingAssetType assetType, + final int expectedCountInTestDataCount, + final int minCountExpectedInProdData) { + final var q = em.createNativeQuery( + "select count(*) from hs_hosting_asset where type = cast(:type as HsHostingAssetType)", + Integer.class); + q.setParameter("type", assetType.name()); + final var count = (Integer) q.getSingleResult(); + if (isImportingControlledTestData()) { + assertThat(count).isEqualTo(expectedCountInTestDataCount); + } else { + assertThat(count).isGreaterThanOrEqualTo(minCountExpectedInProdData); + } + + } + + private void importIpNumbers(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var ipNumber = HsHostingAssetRealEntity.builder() + .type(IPV4_NUMBER) + .identifier(rec.getString("inet_addr")) + .caption(rec.getString("description")) + .build(); + ipNumberAssets.put(rec.getInteger("inet_addr_id"), ipNumber); + }); + } + + private void importHives(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var hive_id = rec.getInteger("hive_id"); + final var hive = new Hive( + hive_id, + rec.getString("hive_name"), + rec.getInteger("inet_addr_id"), + new AtomicReference<>()); + hives.put(hive_id, hive); + }); + } + + private void importPackets(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var packet_id = rec.getInteger("packet_id"); + final var basepacket_code = rec.getString("basepacket_code"); + final var packet_name = rec.getString("packet_name"); + final var bp_id = rec.getInteger("bp_id"); + final var hive_id = rec.getInteger("hive_id"); + final var created = rec.getLocalDate("created"); + final var cancelled = rec.getLocalDate("cancelled"); + final var cur_inet_addr_id = rec.getInteger("cur_inet_addr_id"); + final var old_inet_addr_id = rec.getInteger("old_inet_addr_id"); + final var free = rec.getBoolean("free"); + + assertThat(old_inet_addr_id) + .as("packet.old_inet_addr_id not supported, but is not null for " + packet_name) + .isNull(); + + final var biType = determineBiType(basepacket_code); + final var bookingItem = HsBookingItemRealEntity.builder() + .type(biType) + .caption("BI " + packet_name) + .project(bookingProjects.get(bp_id)) + .validity(toPostgresDateRange(created, cancelled)) + .build(); + bookingItems.put(packet_id, bookingItem); + final var haType = determineHaType(basepacket_code); + + logError(() -> assertThat(!free || haType == MANAGED_WEBSPACE || bookingItem.getRelatedProject() + .getDebitor() + .getDefaultPrefix() + .equals("hsh")) + .as("packet.free only supported for Hostsharing-Assets and ManagedWebspace in customer-ManagedServer, but is set for " + + packet_name) + .isTrue()); + + final var asset = HsHostingAssetRealEntity.builder() + // this turns off identifier validation to accept former default prefixes + .isLoaded(haType == MANAGED_WEBSPACE) + .type(haType) + .identifier(packet_name) + .bookingItem(bookingItem) + .caption("HA " + packet_name) + .build(); + packetAssets.put(packet_id, asset); + if (haType == MANAGED_SERVER) { + hive(hive_id).serverRef.set(asset); + } + if (cur_inet_addr_id != null) { + ipNumber(cur_inet_addr_id).setAssignedToAsset(asset); + } + }); + + // once we know all hosting assets, we can set the parentAsset for managed webspaces + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var packet_id = rec.getInteger("packet_id"); + final var basepacket_code = rec.getString("basepacket_code"); + final var hive_id = rec.getInteger("hive_id"); + + final var haType = determineHaType(basepacket_code); + if (haType == MANAGED_WEBSPACE) { + final var managedWebspace = pac(packet_id); + final var parentAsset = hive(hive_id).serverRef.get(); + managedWebspace.setParentAsset(parentAsset); + + if (parentAsset.getRelatedProject() != managedWebspace.getRelatedProject() + && managedWebspace.getRelatedProject().getDebitor().getDebitorNumber() == 10000_00 ) { + assertThat(managedWebspace.getIdentifier()).startsWith("xyz"); + final var hshDebitor = managedWebspace.getBookingItem().getProject().getDebitor(); + final var newProject = HsBookingProjectRealEntity.builder() + .debitor(hshDebitor) + .caption(parentAsset.getIdentifier() + " Monitor") + .build(); + bookingProjects.put(Collections.max(bookingProjects.keySet())+1, newProject); + managedWebspace.getBookingItem().setProject(newProject); + } else { + managedWebspace.getBookingItem().setParentItem(parentAsset.getBookingItem()); + } + } + }); + } + + private void importPacketComponents(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + // final var packet_component_id = rec.getInteger("packet_component_id"); not needed + final var packet_id = rec.getInteger("packet_id"); + final var quantity = rec.getInteger("quantity"); + final var basecomponent_code = rec.getString("basecomponent_code"); + // final var created = rec.getLocalDate("created"); TODO.spec: can we do without? + // final var cancelled = rec.getLocalDate("cancelled"); TODO.spec: can we do without? + Function convert = (v -> v); + + final var asset = pac(packet_id); + final var name = switch (basecomponent_code) { + case "DAEMON" -> "Daemons"; + case "MULTI" -> "Multi"; + case "CPU" -> "CPU"; + case "RAM" -> returning("RAM", convert = v -> v / 1024); + case "QUOTA" -> returning("SSD", convert = v -> v / 1024); + case "STORAGE" -> returning("HDD", convert = v -> v / 1024); + case "TRAFFIC" -> "Traffic"; + case "OFFICE" -> returning("Online Office Server", convert = v -> v == 1); + + case "SLABASIC" -> switch (asset.getType()) { + case CLOUD_SERVER -> "SLA-Infrastructure"; + case MANAGED_SERVER -> "SLA-Platform"; + case MANAGED_WEBSPACE -> "SLA-Platform"; + default -> throw new IllegalArgumentException("SLABASIC not defined for " + asset.getType()); + }; + + case "SLAINFR2H" -> "SLA-Infrastructure"; + case "SLAINFR4H" -> "SLA-Infrastructure"; + case "SLAINFR8H" -> "SLA-Infrastructure"; + + case "SLAEXT24H" -> "SLA-Platform"; + + case "SLAPLAT2H" -> "SLA-Platform"; + case "SLAPLAT4H" -> "SLA-Platform"; + case "SLAPLAT8H" -> "SLA-Platform"; + + case "SLAWEB2H" -> "SLA-Web"; + case "SLAWEB4H" -> "SLA-Web"; + case "SLAWEB8H" -> "SLA-Web"; + + case "SLAMAIL2H" -> "SLA-EMail"; + case "SLAMAIL4H" -> "SLA-EMail"; + case "SLAMAIL8H" -> "SLA-EMail"; + + case "SLAMARIA2H" -> "SLA-Maria"; + case "SLAMARIA4H" -> "SLA-Maria"; + case "SLAMARIA8H" -> "SLA-Maria"; + + case "SLAPGSQL2H" -> "SLA-PgSQL"; + case "SLAPGSQL4H" -> "SLA-PgSQL"; + case "SLAPGSQL8H" -> "SLA-PgSQL"; + + case "SLAOFFIC2H" -> "SLA-Office"; + case "SLAOFFIC4H" -> "SLA-Office"; + case "SLAOFFIC8H" -> "SLA-Office"; + + case "BANDWIDTH" -> "Bandwidth"; + default -> throw new IllegalArgumentException("unknown basecomponent_code: " + basecomponent_code); + }; + + if (name.equals("SLA-Infrastructure")) { + final var slaValue = switch (basecomponent_code) { + case "SLABASIC" -> "BASIC"; + case "SLAINFR2H" -> "EXT2H"; + case "SLAINFR4H" -> "EXT4H"; + case "SLAINFR8H" -> "EXT8H"; + default -> throw new IllegalArgumentException("unknown basecomponent_code: " + basecomponent_code); + }; + asset.getBookingItem().getResources().put(name, slaValue); + } else if (name.equals("SLA-Platform")) { + final var slaValue = switch (basecomponent_code) { + case "SLABASIC" -> "BASIC"; + case "SLAEXT24H" -> "EXT24H"; + case "SLAPLAT2H" -> "EXT2H"; + case "SLAPLAT4H" -> "EXT4H"; + case "SLAPLAT8H" -> "EXT8H"; + default -> throw new IllegalArgumentException("unknown basecomponent_code: " + basecomponent_code); + }; + if (ofNullable(asset.getBookingItem().getResources().get(name)).map("BASIC"::equals).orElse(true)) { + asset.getBookingItem().getResources().put(name, slaValue); + } + } else if (name.startsWith("SLA")) { + asset.getBookingItem().getResources().put(name, true); + } else if (quantity > 0) { + asset.getBookingItem().getResources().put(name, convert.apply(quantity)); + } + }); + } + + private void importUnixUsers(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var unixuser_id = rec.getInteger("unixuser_id"); + final var packet_id = rec.getInteger("packet_id"); + final var parentWebspaceAsset = packetAssets.get(packet_id); + final var unixUserAsset = HsHostingAssetRealEntity.builder() + .type(UNIX_USER) + .parentAsset(parentWebspaceAsset) + .identifier(rec.getString("name")) + .caption(rec.getString("comment")) + .isLoaded(true) // avoid overwriting imported userids with generated ids + .config(new HashMap<>(ofEntries( + entry("shell", rec.getString("shell")), + // entry("homedir", rec.getString("homedir")), do not import, it's calculated + entry("locked", rec.getBoolean("locked")), + entry("userid", rec.getInteger("userid")), + entry("SSD soft quota", rec.getInteger("quota_softlimit")), + entry("SSD hard quota", rec.getInteger("quota_hardlimit")), + entry("HDD soft quota", rec.getInteger("storage_softlimit")), + entry("HDD hard quota", rec.getInteger("storage_hardlimit")) + ))) + .build(); + + if (unixUserAsset.getIdentifier().equals(parentWebspaceAsset.getIdentifier())) { + parentWebspaceAsset.getConfig().put("groupid", unixuser_id); + } + + // TODO.spec: crop SSD+HDD limits if > booked + if (unixUserAsset.getDirectValue("SSD hard quota", Integer.class, 0) + > 1024 * unixUserAsset.getContextValue("SSD", Integer.class, 0)) { + unixUserAsset.getConfig() + .put("SSD hard quota", unixUserAsset.getContextValue("SSD", Integer.class, 0) * 1024); + } + if (unixUserAsset.getDirectValue("HDD hard quota", Integer.class, 0) + > 1024 * unixUserAsset.getContextValue("HDD", Integer.class, 0)) { + unixUserAsset.getConfig() + .put("HDD hard quota", unixUserAsset.getContextValue("HDD", Integer.class, 0) * 1024); + } + + // TODO.spec: does `softlimit unixUserAsset.getDirectValue("SSD hard quota", Integer.class, 0)) { + unixUserAsset.getConfig().put("SSD soft quota", unixUserAsset.getConfig().get("SSD hard quota")); + } + if (unixUserAsset.getDirectValue("HDD soft quota", Integer.class, 0) + > unixUserAsset.getDirectValue("HDD hard quota", Integer.class, 0)) { + unixUserAsset.getConfig().put("HDD soft quota", unixUserAsset.getConfig().get("HDD hard quota")); + } + + // TODO.spec: remove HDD limits if no HDD storage is booked + if (unixUserAsset.getContextValue("HDD", Integer.class, 0) == 0) { + unixUserAsset.getConfig().remove("HDD hard quota"); + unixUserAsset.getConfig().remove("HDD soft quota"); + } + + unixUserAssets.put(unixuser_id, unixUserAsset); + }); + } + + private void importEmailAliases(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var emailalias_id = rec.getInteger("emailalias_id"); + final var packet_id = rec.getInteger("pac_id"); + final var targets = parseCsvLine(rec.getString("target")); + final var emailAliasAsset = HsHostingAssetRealEntity.builder() + .type(EMAIL_ALIAS) + .parentAsset(packetAssets.get(packet_id)) + .identifier(rec.getString("name")) + .caption(rec.getString("name")) + .config(ofEntries( + entry("target", targets) + )) + .build(); + emailAliasAssets.put(emailalias_id, emailAliasAsset); + }); + } + + private void createDatabaseInstances(final List parentAssets) { + final var idRef = new AtomicInteger(0); + parentAssets.forEach(pa -> { + if (pa.getSubHostingAssets() == null) { + pa.setSubHostingAssets(new ArrayList<>()); + } + + final var pgSqlInstanceAsset = HsHostingAssetRealEntity.builder() + .type(PGSQL_INSTANCE) + .parentAsset(pa) + .identifier(pa.getIdentifier() + "|PgSql.default") + .caption(pa.getIdentifier() + "-PostgreSQL default instance") + .build(); + pa.getSubHostingAssets().add(pgSqlInstanceAsset); + dbInstanceAssets.put(idRef.getAndIncrement(), pgSqlInstanceAsset); + + final var mariaDbInstanceAsset = HsHostingAssetRealEntity.builder() + .type(MARIADB_INSTANCE) + .parentAsset(pa) + .identifier(pa.getIdentifier() + "|MariaDB.default") + .caption(pa.getIdentifier() + "-MariaDB default instance") + .build(); + pa.getSubHostingAssets().add(mariaDbInstanceAsset); + dbInstanceAssets.put(idRef.getAndIncrement(), mariaDbInstanceAsset); + }); + } + + private void importDatabaseUsers(final String[] header, final List records) { + HashGenerator.enableCouldBeHash(true); + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var dbuser_id = rec.getInteger("dbuser_id"); + final var packet_id = rec.getInteger("packet_id"); + final var engine = rec.getString("engine"); + final HsHostingAssetType dbUserAssetType = "mysql".equals(engine) ? MARIADB_USER + : "pgsql".equals(engine) ? PGSQL_USER + : failWith("unknown DB engine " + engine); + final var hash = dbUserAssetType == MARIADB_USER ? Algorithm.MYSQL_NATIVE : Algorithm.SCRAM_SHA256; + final var name = rec.getString("name"); + final var password_hash = rec.getString( + "password_hash", + HashGenerator.using(hash).withRandomSalt().hash("fake pw " + name)); + + final HsHostingAssetType dbInstanceAssetType = "mysql".equals(engine) ? MARIADB_INSTANCE + : "pgsql".equals(engine) ? PGSQL_INSTANCE + : failWith("unknown DB engine " + engine); + final var relatedWebspaceHA = packetAssets.get(packet_id).getParentAsset(); + final var dbInstanceAsset = relatedWebspaceHA.getSubHostingAssets().stream() + .filter(ha -> ha.getType() == dbInstanceAssetType) + .findAny().orElseThrow(); // there is exactly one: the default instance for the given type + + final var dbUserAsset = HsHostingAssetRealEntity.builder() + .type(dbUserAssetType) + .parentAsset(packetAssets.get(packet_id)) + .assignedToAsset(dbInstanceAsset) + .identifier(dbUserAssetType.name().substring(0, 2) + "U|" + name) + .caption(name) + .config(new HashMap<>(ofEntries( + entry("password", password_hash) + ))) + .build(); + dbUsersByEngineAndName.put(engine + ":" + name, dbUserAsset); + dbUserAssets.put(dbuser_id, dbUserAsset); + }); + } + + private void importDatabases(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var database_id = rec.getInteger("database_id"); + final var engine = rec.getString("engine"); + final var owner = rec.getString("owner"); + final var owningDbUserHA = dbUsersByEngineAndName.get(engine + ":" + owner); + assertThat(owningDbUserHA).as("owning user for " + (engine + ":" + owner) + " not found").isNotNull(); + final HsHostingAssetType type = "mysql".equals(engine) ? MARIADB_DATABASE + : "pgsql".equals(engine) ? PGSQL_DATABASE + : failWith("unknown DB engine " + engine); + final var name = rec.getString("name"); + final var encoding = rec.getString("encoding").replaceAll("[-_]+", ""); + final var dbAsset = HsHostingAssetRealEntity.builder() + .type(type) + .parentAsset(owningDbUserHA) + .identifier(type.name().substring(0, 2) + "D|" + name) + .caption(name) + .config(ofEntries( + entry( + "encoding", + type == MARIADB_DATABASE ? encoding.toLowerCase() : encoding.toUpperCase()) + )) + .build(); + dbAssets.put(database_id, dbAsset); + }); + } + + private void importDomains(final String[] header, final List records) { + final var httpDomainSetupValidator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_HTTP_SETUP); + + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + final var domain_id = rec.getInteger("domain_id"); + final var domain_name = rec.getString("domain_name"); + // final var domain_since = rec.getString("domain_since"); TODO.spec: to related BookingItem? + // final var domain_dns_master = rec.getString("domain_dns_master"); TODO.spec: do we need this and where? + final var owner_id = rec.getInteger("domain_owner"); + final var domainoptions = rec.getString("domainoptions"); + + // Domain Setup + final var domainSetupAsset = HsHostingAssetRealEntity.builder() + // .bookingItem(bookingItem) are set once we've collected all domains + .type(DOMAIN_SETUP) + // .parentAsset(parentDomainSetupAsset) are set once we've collected all of them + .identifier(domain_name) + .caption(IDN.toUnicode(domain_name)) + .config(ofEntries( + // nothing here + )) + .build(); + domainSetupAsset.markAsLoaded(); // to skip setup verification + domainSetupsByName.put(domain_name, domainSetupAsset); + domainSetupAssets.put(domain_id, domainSetupAsset); + domainSetupAsset.setSubHostingAssets(new ArrayList<>()); + + // Domain DNS Setup + final var ownerAsset = unixUserAssets.get(owner_id); + final var webspaceAsset = ownerAsset.getParentAsset(); + assertThat(webspaceAsset.getType()).isEqualTo(MANAGED_WEBSPACE); + final var domainDnsSetupAsset = HsHostingAssetRealEntity.builder() + .type(DOMAIN_DNS_SETUP) + .parentAsset(domainSetupAsset) + .assignedToAsset(webspaceAsset) + .identifier(domain_name + "|DNS") + .caption("DNS-Setup für " + IDN.toUnicode(domain_name)) + .config(new HashMap<>()) // is read from separate files + .build(); + domainDnsSetupAssets.put(domain_id, domainDnsSetupAsset); + domainSetupAsset.getSubHostingAssets().add(domainDnsSetupAsset); + + // Domain HTTP Setup + final var options = stream(domainoptions.split(",")).collect(toSet()); + final var domainHttpSetupAsset = HsHostingAssetRealEntity.builder() + .type(DOMAIN_HTTP_SETUP) + .parentAsset(domainSetupAsset) + .assignedToAsset(ownerAsset) + .identifier(domain_name + "|HTTP") + .caption("HTTP-Setup für " + IDN.toUnicode(domain_name)) + .config(ofEntries( + entry("htdocsfallback", options.contains("htdocsfallback")), + entry("indexes", options.contains("indexes")), + entry("cgi", options.contains("cgi")), + entry("passenger", options.contains("passenger")), + entry("passenger-errorpage", options.contains("passenger-errorpage")), + entry("fastcgi", options.contains("fastcgi")), + entry("autoconfig", options.contains("autoconfig")), + entry("greylisting", options.contains("greylisting")), + entry("includes", options.contains("includes")), + entry("letsencrypt", options.contains("letsencrypt")), + entry("multiviews", options.contains("multiviews")), + entry("subdomains", withDefault(rec.getString("valid_subdomain_names"), "*") + .split(",")), + entry("fcgi-php-bin", withDefault( + rec.getString("fcgi_php_bin"), + httpDomainSetupValidator.getProperty("fcgi-php-bin").defaultValue())), + entry("passenger-nodejs", withDefault( + rec.getString("passenger_nodejs"), + httpDomainSetupValidator.getProperty("passenger-nodejs").defaultValue())), + entry("passenger-python", withDefault( + rec.getString("passenger_python"), + httpDomainSetupValidator.getProperty("passenger-python").defaultValue())), + entry("passenger-ruby", withDefault( + rec.getString("passenger_ruby"), + httpDomainSetupValidator.getProperty("passenger-ruby").defaultValue())) + )) + .build(); + domainHttpSetupAssets.put(domain_id, domainHttpSetupAsset); + domainSetupAsset.getSubHostingAssets().add(domainHttpSetupAsset); + + // Domain MBOX Setup + final var domainMboxSetupAsset = HsHostingAssetRealEntity.builder() + .type(DOMAIN_MBOX_SETUP) + .parentAsset(domainSetupAsset) + .assignedToAsset(webspaceAsset) + .identifier(domain_name + "|MBOX") + .caption("E-Mail-Empfang-Setup für " + IDN.toUnicode(domain_name)) + .config(ofEntries( + // no properties available + )) + .subHostingAssets(new ArrayList<>()) + .build(); + domainMBoxSetupAssets.put(domain_id, domainMboxSetupAsset); + domainSetupAsset.getSubHostingAssets().add(domainMboxSetupAsset); + + // Domain SMTP Setup + final var domainSmtpSetupAsset = HsHostingAssetRealEntity.builder() + .type(DOMAIN_SMTP_SETUP) + .parentAsset(domainSetupAsset) + .assignedToAsset(webspaceAsset) + .identifier(domain_name + "|SMTP") + .caption("E-Mail-Versand-Setup für " + IDN.toUnicode(domain_name)) + .config(ofEntries( + // no properties available + )) + .build(); + domainSmtpSetupAssets.put(domain_id, domainSmtpSetupAsset); + domainSetupAsset.getSubHostingAssets().add(domainSmtpSetupAsset); + }); + + domainSetupsByName.values().forEach(domainSetup -> { + final var parentDomainName = domainSetup.getIdentifier().split("\\.", 2)[1]; + final var parentDomainSetup = domainSetupsByName.get(parentDomainName); + if (parentDomainSetup != null) { + domainSetup.setParentAsset(parentDomainSetup); + } else { + final var relatedProject = domainSetup.getSubHostingAssets().stream() + .map(ha -> ha.getAssignedToAsset() != null ? ha.getAssignedToAsset().getRelatedProject() : null) + .findAny().orElseThrow(); + final var bookingItem = HsBookingItemRealEntity.builder() + .type(HsBookingItemType.DOMAIN_SETUP) + .caption("BI " + domainSetup.getIdentifier()) + .project((HsBookingProjectRealEntity) relatedProject) + //.validity(toPostgresDateRange(created, cancelled)) + .resources(Map.ofEntries( + entry("domainName", domainSetup.getIdentifier()))) + .build(); + domainSetup.setBookingItem(bookingItem); + bookingItems.put(nextAvailableBookingItemId(), bookingItem); + + } + }); + } + + private static @NotNull Integer nextAvailableBookingItemId() { + return bookingItems.keySet().stream().max(Long::compare).map(id -> id + 1).orElseThrow(); + } + + private String withDefault(final String givenValue, final Object defaultValue) { + if (defaultValue instanceof String defaultStringValue) { + return givenValue != null && !givenValue.isBlank() ? givenValue : defaultStringValue; + } + throw new RuntimeException( + "property default value expected to be of type string, but is of type " + defaultValue.getClass() + .getSimpleName()); + } + + private void importZonefiles(final String vmName, final String zonenfilesJson) { + if (zonenfilesJson == null || zonenfilesJson.isEmpty() || zonenfilesJson.isBlank()) { + return; + } + + try { + //noinspection unchecked + final Map> zoneData = jsonMapper.readValue(zonenfilesJson, Map.class); + importZonenfile(vmName, zoneData); + } catch (JsonProcessingException e) { + throw new RuntimeException("cannot read zonefile JSON: '" + zonenfilesJson + "'", e); + } + } + + private void importZonenfile(final String vmName, final Map> zoneDataForVM) { + zoneDataForVM.forEach((domainName, zoneData) -> { + final var domainAsset = domainSetupsByName.get(domainName); + if (domainAsset != null) { + final var domainDnsSetupAsset = domainAsset.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType() == DOMAIN_DNS_SETUP) + .findAny().orElse(null); + assertThat(domainDnsSetupAsset).as(domainAsset.getIdentifier() + " has no DOMAIN_DNS_SETUP").isNotNull(); + + final var domUser = domainAsset.getSubHostingAssets().stream() + .filter(ha -> ha.getType() == DOMAIN_HTTP_SETUP) + .findAny().orElseThrow() + .getAssignedToAsset(); + final var domOwner = zoneData.remove("DOM_OWNER"); + final var expectedDomOwner = domUser.getIdentifier(); + if (domOwner.equals(expectedDomOwner)) { + logError(() -> assertThat(vmName).isEqualTo(domUser.getParentAsset().getParentAsset().getIdentifier())); + + //noinspection unchecked + zoneData.put("user-RR", ((ArrayList>) zoneData.get("user-RR")).stream() + .map(userRR -> userRR.stream().map(Object::toString).collect(Collectors.joining(" "))) + .toArray(String[]::new) + ); + domainDnsSetupAsset.getConfig().putAll(zoneData); + } else { + logError("zonedata dom_owner of " + domainAsset.getIdentifier() + " is " + domOwner + " but expected to be " + + expectedDomOwner); + } + } + }); + } + + private void importEmailAddresses(final String[] header, final List records) { + final var columns = new Columns(header); + records.stream() + .map(this::trimAll) + .map(row -> new Record(columns, row)) + .forEach(rec -> { + // emailaddr_id;domain_id;localpart;subdomain;target + final var emailaddr_id = rec.getInteger("emailaddr_id"); + final var domain_id = rec.getInteger("domain_id"); + final var localpart = rec.getString("localpart"); + final var subdomain = rec.getString("subdomain"); + final var targets = stream(parseCsvLine(rec.getString("target"))) + .map(t -> NOBODY_SUBSTITUTES.contains(t) ? "nobody" : t) + .toArray(String[]::new); + final var domainMboxSetup = domainMBoxSetupAssets.get(domain_id); + final var domainSetup = domainMboxSetup.getParentAsset(); + final var emailAddress = localpart + "@" + + (subdomain != null && !subdomain.isBlank() ? subdomain + "." : "") + domainSetup.getIdentifier(); + final var emailAddressAsset = HsHostingAssetRealEntity.builder() + .type(EMAIL_ADDRESS) + .parentAsset(domainMboxSetup) + .identifier(emailAddress) + .caption(emailAddress) + .config(ofNonNullEntries( + entryIfNotNull("local-part", localpart), + entryIfNotNull("sub-domain", subdomain), + entry("target", targets) + )) + .build(); + emailAddressAssets.put(emailaddr_id, emailAddressAsset); + domainMboxSetup.getSubHostingAssets().add(emailAddressAsset); + }); + } + + @SafeVarargs + private static Map ofNonNullEntries(final Map.Entry... entries) { + //noinspection unchecked + return ofEntries(stream(entries).filter(Objects::nonNull).toArray(Map.Entry[]::new)); + } + + private static Map.Entry entryIfNotNull(final String key, final @Nullable String value) { + if (value == null || value.isBlank()) { + return null; + } + return entry(key, value); + } + + // ============================================================================================ + + V returning( + final V value, + @SuppressWarnings("unused") final Object... assignments // DSL-hack: just used for side effects on caller-side + ) { + return value; + } + + private static @NotNull HsBookingItemType determineBiType(final String basepacket_code) { + return switch (basepacket_code) { + case "SRV/CLD" -> HsBookingItemType.CLOUD_SERVER; + case "SRV/MGD" -> HsBookingItemType.MANAGED_SERVER; + case "PAC/WEB" -> HsBookingItemType.MANAGED_WEBSPACE; + default -> throw new IllegalArgumentException( + "unknown basepacket_code: " + basepacket_code); + }; + } + + private static @NotNull HsHostingAssetType determineHaType(final String basepacket_code) { + return switch (basepacket_code) { + case "SRV/CLD" -> CLOUD_SERVER; + case "SRV/MGD" -> MANAGED_SERVER; + case "PAC/WEB" -> MANAGED_WEBSPACE; + default -> throw new IllegalArgumentException( + "unknown basepacket_code: " + basepacket_code); + }; + } + + private static HsHostingAssetRealEntity ipNumber(final Integer inet_addr_id) { + return inet_addr_id != null ? ipNumberAssets.get(inet_addr_id) : null; + } + + private static Hive hive(final Integer hive_id) { + return hive_id != null ? hives.get(hive_id) : null; + } + + private static HsHostingAssetRealEntity pac(final Integer packet_id) { + return packet_id != null ? packetAssets.get(packet_id) : null; + } + + private String firstOfEach( + final int maxCount, + final Map assets) { + return toJsonFormattedString(assets.entrySet().stream().limit(maxCount) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, ImportHostingAssets::uniqueKeys, TreeMap::new))); + } + + private String firstOfEach( + final int maxCount, + final Map assets, + final HsHostingAssetType type) { + return toJsonFormattedString(assets.entrySet().stream() + .filter(hae -> hae.getValue().getType() == type) + .limit(maxCount) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, ImportHostingAssets::uniqueKeys, TreeMap::new))); + } + + protected static V uniqueKeys(final V v1, final V v2) { + throw new RuntimeException(String.format("Duplicate key for values %s and %s", v1, v2)); + } + + private String firstOfEachType( + final int maxCount, + final HsBookingItemType... types) { + return toJsonFormattedString(stream(types) + .flatMap(t -> + bookingItems.entrySet().stream() + .filter(bie -> bie.getValue().getType() == t) + .limit(maxCount) + ) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + private Map first( + final int maxCount, + final Map entities) { + return entities.entrySet().stream() + .limit(maxCount) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + protected static boolean isImportingControlledTestData() { + return MIGRATION_DATA_PATH.equals(TEST_DATA_MIGRATION_DATA_PATH); + } + + protected static void assumeThatWeAreImportingControlledTestData() { + assumeThat(isImportingControlledTestData()).isTrue(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportOfficeData.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportOfficeData.java new file mode 100644 index 00000000..add8f8ec --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportOfficeData.java @@ -0,0 +1,66 @@ +package net.hostsharing.hsadminng.hs.migration; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/* + * This 'test' includes the complete legacy 'office' data import. + * + * There is no code in 'main' because the import is not needed a normal runtime. + * There is some test data in Java resources to verify the data conversion. + * For a real import a main method will be added later + * which reads CSV files from the file system. + * + * When run on a Hostsharing database, it needs the following settings (hsh99_... just examples). + * + * In a real Hostsharing environment, these are created via (the old) hsadmin: + + CREATE USER hsh99_admin WITH PASSWORD 'password'; + CREATE DATABASE hsh99_hsadminng ENCODING 'UTF8' TEMPLATE template0; + REVOKE ALL ON DATABASE hsh99_hsadminng FROM public; -- why does hsadmin do that? + ALTER DATABASE hsh99_hsadminng OWNER TO hsh99_admin; + + CREATE USER hsh99_restricted WITH PASSWORD 'password'; + + \c hsh99_hsadminng + + GRANT ALL PRIVILEGES ON SCHEMA public to hsh99_admin; + + * Additionally, we need these settings (because the Hostsharing DB-Admin has no CREATE right): + + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + -- maybe something like that is needed for the 2nd user + -- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public to hsh99_restricted; + + * Then copy the file .tc-environment to a file named .environment (excluded from git) and fill in your specific values. + + * To finally import the office data, run: + * + * gw-importOfficeTables # comes from .aliases file and uses .environment + */ +@Tag("importOfficeData") +@DataJpaTest(properties = { + "spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///importOfficeDataTC}", + "spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}", + "spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}", + "hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}" +}) +@DirtiesContext +@Import({ Context.class, JpaAttempt.class }) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@ExtendWith(OrderedDependedTestsExtension.class) +public class ImportOfficeData extends BaseOfficeDataImport { + + @BeforeEach + void check() { + assertThat(jdbcUrl).isEqualTo("jdbc:tc:postgresql:15.5-bookworm:///importOfficeDataTC"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerAcceptanceTest.java index a8ab2a7c..540fd2c7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerAcceptanceTest.java @@ -4,8 +4,8 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.json.JSONException; import org.junit.jupiter.api.*; @@ -18,8 +18,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; @@ -29,7 +29,7 @@ import static org.hamcrest.Matchers.startsWith; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeBankAccountControllerAcceptanceTest { +class HsOfficeBankAccountControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort private Integer port; @@ -47,11 +47,10 @@ class HsOfficeBankAccountControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "bankaccount:F(Find)" }) class ListBankAccounts { @Test - void globalAdmin_withoutAssumedRoles_canViewAllBankAaccounts_ifNoCriteriaGiven() throws JSONException { + void globalAdmin_withoutAssumedRoles_canViewAllBankAccounts_ifNoCriteriaGiven() throws JSONException { RestAssured // @formatter:off .given() @@ -75,7 +74,7 @@ class HsOfficeBankAccountControllerAcceptanceTest { "bic": "BYLADEM1001" }, { - "holder": "Fourth e.G.", + "holder": "Fourth eG", "iban": "DE02200505501015871393", "bic": "HASPDEHH" }, @@ -112,7 +111,6 @@ class HsOfficeBankAccountControllerAcceptanceTest { } @Nested - @Accepts({ "bankaccount:C(Create)" }) class CreateBankAccount { @Test @@ -152,7 +150,6 @@ class HsOfficeBankAccountControllerAcceptanceTest { } @Nested - @Accepts({ "bankaccount:R(Read)" }) class GetBankAccount { @Test @@ -177,7 +174,6 @@ class HsOfficeBankAccountControllerAcceptanceTest { } @Test - @Accepts({ "bankaccount:X(Access Control)" }) void normalUser_canNotGetUnrelatedBankAccount() { context.define("superuser-alex@hostsharing.net"); final var givenBankAccountUuid = bankAccountRepo.findByOptionalHolderLike("first").get(0).getUuid(); @@ -257,7 +253,6 @@ class HsOfficeBankAccountControllerAcceptanceTest { } @Nested - @Accepts({ "bankaccount:D(Delete)" }) class DeleteBankAccount { @Test @@ -279,7 +274,6 @@ class HsOfficeBankAccountControllerAcceptanceTest { } @Test - @Accepts({ "bankaccount:X(Access Control)" }) void bankaccountOwner_canDeleteRelatedBankAaccount() { final var givenBankAccount = givenSomeTemporaryBankAccountCreatedBy("selfregistered-test-user@hostsharing.org"); @@ -293,11 +287,13 @@ class HsOfficeBankAccountControllerAcceptanceTest { .statusCode(204); // @formatter:on // then the given bankaccount is still there - assertThat(bankAccountRepo.findByUuid(givenBankAccount.getUuid())).isEmpty(); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + assertThat(bankAccountRepo.findByUuid(givenBankAccount.getUuid())).isEmpty(); + }).assertSuccessful(); } @Test - @Accepts({ "bankaccount:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedBankAccount() { context.define("superuser-alex@hostsharing.net"); final var givenBankAccount = givenSomeTemporaryBankAccountCreatedBy("selfregistered-test-user@hostsharing.org"); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java index d870ca1a..37f85f83 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java @@ -77,7 +77,7 @@ class HsOfficeBankAccountControllerRestTest { .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is(testCase.expectedErrorMessage()))); + .andExpect(jsonPath("message", is("ERROR: [400] " + testCase.expectedErrorMessage()))); } enum InvalidBicTestCase { @@ -124,6 +124,6 @@ class HsOfficeBankAccountControllerRestTest { .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is(testCase.expectedErrorMessage()))); + .andExpect(jsonPath("message", is("ERROR: [400] " + testCase.expectedErrorMessage()))); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntityUnitTest.java index 9fea3b5e..acd6c8f3 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntityUnitTest.java @@ -19,7 +19,7 @@ class HsOfficeBankAccountEntityUnitTest { .iban("DE02370502990000684712") .bic("COKSDE33") .build(); - assertThat("" + givenBankAccount).isEqualTo("bankAccount(holder='given holder', iban='DE02370502990000684712', bic='COKSDE33')"); + assertThat(givenBankAccount.toString()).isEqualTo("bankAccount(DE02370502990000684712: holder='given holder', bic='COKSDE33')"); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java index 4861d2c1..5fbd89a3 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java @@ -1,14 +1,12 @@ package net.hostsharing.hsadminng.hs.office.bankaccount; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -24,14 +22,14 @@ import java.util.List; import java.util.function.Supplier; import static net.hostsharing.hsadminng.hs.office.bankaccount.TestHsOfficeBankAccount.hsOfficeBankAccount; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import({ Context.class, JpaAttempt.class }) -class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeBankAccountRepository bankAccountRepo; @@ -61,8 +59,8 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { final var count = bankAccountRepo.count(); // when - final var result = attempt(em, () -> bankAccountRepo.save( - hsOfficeBankAccount("some temp acc A", "DE37500105177419788228", ""))); + final var result = attempt(em, () -> toCleanup(bankAccountRepo.save( + hsOfficeBankAccount("some temp acc A", "DE37500105177419788228", "")))); // then result.assertSuccessful(); @@ -78,8 +76,8 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { final var count = bankAccountRepo.count(); // when - final var result = attempt(em, () -> bankAccountRepo.save( - hsOfficeBankAccount("some temp acc B", "DE49500105174516484892", "INGDDEFFXXX"))); + final var result = attempt(em, () -> toCleanup(bankAccountRepo.save( + hsOfficeBankAccount("some temp acc B", "DE49500105174516484892", "INGDDEFFXXX")))); // then result.assertSuccessful(); @@ -92,42 +90,40 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("selfregistered-user-drew@hostsharing.org"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); // when - attempt(em, () -> bankAccountRepo.save( - hsOfficeBankAccount("some temp acc C", "DE25500105176934832579", "INGDDEFFXXX")) + attempt(em, () -> toCleanup(bankAccountRepo.save( + hsOfficeBankAccount("some temp acc C", "DE25500105176934832579", "INGDDEFFXXX"))) ).assertSuccessful(); // then final var roles = rawRoleRepo.findAll(); - assertThat(roleNamesOf(roles)).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(roles)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_office_bankaccount#sometempaccC.owner", - "hs_office_bankaccount#sometempaccC.admin", - "hs_office_bankaccount#sometempaccC.tenant", - "hs_office_bankaccount#sometempaccC.guest" + "hs_office_bankaccount#DE25500105176934832579:OWNER", + "hs_office_bankaccount#DE25500105176934832579:ADMIN", + "hs_office_bankaccount#DE25500105176934832579:REFERRER" )); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromFormatted( + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, - "{ grant perm delete on hs_office_bankaccount#sometempaccC to role hs_office_bankaccount#sometempaccC.owner by system and assume }", - "{ grant role hs_office_bankaccount#sometempaccC.owner to role global#global.admin by system and assume }", - "{ grant role hs_office_bankaccount#sometempaccC.owner to user selfregistered-user-drew@hostsharing.org by global#global.admin and assume }", + "{ grant perm:hs_office_bankaccount#DE25500105176934832579:DELETE to role:hs_office_bankaccount#DE25500105176934832579:OWNER by system and assume }", + "{ grant role:hs_office_bankaccount#DE25500105176934832579:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant role:hs_office_bankaccount#DE25500105176934832579:OWNER to user:selfregistered-user-drew@hostsharing.org by hs_office_bankaccount#DE25500105176934832579:OWNER and assume }", - "{ grant role hs_office_bankaccount#sometempaccC.admin to role hs_office_bankaccount#sometempaccC.owner by system and assume }", + "{ grant role:hs_office_bankaccount#DE25500105176934832579:ADMIN to role:hs_office_bankaccount#DE25500105176934832579:OWNER by system and assume }", + "{ grant perm:hs_office_bankaccount#DE25500105176934832579:UPDATE to role:hs_office_bankaccount#DE25500105176934832579:ADMIN by system and assume }", - "{ grant role hs_office_bankaccount#sometempaccC.tenant to role hs_office_bankaccount#sometempaccC.admin by system and assume }", - - "{ grant perm view on hs_office_bankaccount#sometempaccC to role hs_office_bankaccount#sometempaccC.guest by system and assume }", - "{ grant role hs_office_bankaccount#sometempaccC.guest to role hs_office_bankaccount#sometempaccC.tenant by system and assume }", + "{ grant perm:hs_office_bankaccount#DE25500105176934832579:SELECT to role:hs_office_bankaccount#DE25500105176934832579:REFERRER by system and assume }", + "{ grant role:hs_office_bankaccount#DE25500105176934832579:REFERRER to role:hs_office_bankaccount#DE25500105176934832579:ADMIN by system and assume }", null )); } private void assertThatBankAccountIsPersisted(final HsOfficeBankAccountEntity saved) { final var found = bankAccountRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -147,7 +143,7 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { result, "Anita Bessler", "First GmbH", - "Fourth e.G.", + "Fourth eG", "Mel Bessler", "Paul Winkler", "Peter Smith", @@ -174,7 +170,7 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net", null); // when - final var result = bankAccountRepo.findByIbanOrderByIban("DE02120300000000202051"); + final var result = bankAccountRepo.findByIbanOrderByIbanAsc("DE02120300000000202051"); // then exactlyTheseBankAccountsAreReturned(result, "First GmbH"); @@ -187,7 +183,7 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { // when: context("selfregistered-user-drew@hostsharing.org"); - final var result = bankAccountRepo.findByIbanOrderByIban(givenBankAccount.getIban()); + final var result = bankAccountRepo.findByIbanOrderByIbanAsc(givenBankAccount.getIban()); // then: exactlyTheseBankAccountsAreReturned(result, givenBankAccount.getHolder()); @@ -240,13 +236,9 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { public void deletingABankAccountAlsoDeletesRelatedRolesAndGrants() { // given context("selfregistered-user-drew@hostsharing.org", null); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); final var givenBankAccount = givenSomeTemporaryBankAccount("selfregistered-user-drew@hostsharing.org"); - assertThat(rawRoleRepo.findAll().size()).as("unexpected number of roles created") - .isEqualTo(initialRoleNames.size() + 4); - assertThat(rawGrantRepo.findAll().size()).as("unexpected number of grants created") - .isEqualTo(initialGrantNames.size() + 7); // when final var result = jpaAttempt.transacted(() -> { @@ -257,10 +249,10 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( initialRoleNames )); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from( initialGrantNames )); } @@ -271,7 +263,7 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { Supplier entitySupplier) { return jpaAttempt.transacted(() -> { context(createdByUser); - return bankAccountRepo.save(entitySupplier.get()); + return toCleanup(bankAccountRepo.save(entitySupplier.get())); }).assertSuccessful().returnedValue(); } @@ -279,9 +271,8 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'iban' + from tx_journal_v where targettable = 'hs_office_bankaccount'; """); @@ -290,19 +281,9 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating bankaccount test-data First GmbH, hs_office_bankaccount, INSERT]", - "[creating bankaccount test-data Second e.K., hs_office_bankaccount, INSERT]"); - } - - @BeforeEach - @AfterEach - void cleanup() { - context("superuser-alex@hostsharing.net", null); - final var result = bankAccountRepo.findByOptionalHolderLike("some temp acc"); - result.forEach(tempPerson -> { - System.out.println("DELETING temporary bankaccount: " + tempPerson.getHolder()); - bankAccountRepo.deleteByUuid(tempPerson.getUuid()); - }); + "[creating bankaccount test-data, hs_office_bankaccount, INSERT, DE02120300000000202051]", + "[creating bankaccount test-data, hs_office_bankaccount, INSERT, DE02500105170137075030]", + "[creating bankaccount test-data, hs_office_bankaccount, INSERT, DE02100500000054540402]"); } private HsOfficeBankAccountEntity givenSomeTemporaryBankAccount(final String createdByUser) { @@ -317,17 +298,17 @@ class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest { void exactlyTheseBankAccountsAreReturned( final List actualResult, - final String... bankaccountLabels) { + final String... bankaccountCaptions) { assertThat(actualResult) .extracting(HsOfficeBankAccountEntity::getHolder) - .containsExactlyInAnyOrder(bankaccountLabels); + .containsExactlyInAnyOrder(bankaccountCaptions); } void allTheseBankAccountsAreReturned( final List actualResult, - final String... bankaccountLabels) { + final String... bankaccountCaptions) { assertThat(actualResult) .extracting(HsOfficeBankAccountEntity::getHolder) - .contains(bankaccountLabels); + .contains(bankaccountCaptions); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java index 536043e2..4bd2a4be 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java @@ -4,8 +4,8 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; @@ -19,10 +19,11 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.util.Map; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; @@ -32,7 +33,7 @@ import static org.hamcrest.Matchers.startsWith; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeContactControllerAcceptanceTest { +class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort private Integer port; @@ -44,7 +45,7 @@ class HsOfficeContactControllerAcceptanceTest { Context contextMock; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRbacRepository contactRepo; @Autowired JpaAttempt jpaAttempt; @@ -53,7 +54,6 @@ class HsOfficeContactControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "Contact:F(Find)" }) class ListContacts { @Test @@ -70,18 +70,18 @@ class HsOfficeContactControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" [ - { "label": "first contact" }, - { "label": "second contact" }, - { "label": "third contact" }, - { "label": "forth contact" }, - { "label": "fifth contact" }, - { "label": "sixth contact" }, - { "label": "seventh contact" }, - { "label": "eighth contact" }, - { "label": "ninth contact" }, - { "label": "tenth contact" }, - { "label": "eleventh contact" }, - { "label": "twelfth contact" } + { "caption": "first contact" }, + { "caption": "second contact" }, + { "caption": "third contact" }, + { "caption": "fourth contact" }, + { "caption": "fifth contact" }, + { "caption": "sixth contact" }, + { "caption": "seventh contact" }, + { "caption": "eighth contact" }, + { "caption": "ninth contact" }, + { "caption": "tenth contact" }, + { "caption": "eleventh contact" }, + { "caption": "twelfth contact" } ] """ )); @@ -90,7 +90,6 @@ class HsOfficeContactControllerAcceptanceTest { } @Nested - @Accepts({ "Contact:C(Create)" }) class AddContact { @Test @@ -104,8 +103,10 @@ class HsOfficeContactControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "label": "Temp Contact", - "emailAddresses": "test@example.org" + "caption": "Temp Contact", + "emailAddresses": { + "main": "test@example.org" + } } """) .port(port) @@ -115,8 +116,8 @@ class HsOfficeContactControllerAcceptanceTest { .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("label", is("Temp Contact")) - .body("emailAddresses", is("test@example.org")) + .body("caption", is("Temp Contact")) + .body("emailAddresses", is(Map.of("main", "test@example.org"))) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on @@ -128,13 +129,12 @@ class HsOfficeContactControllerAcceptanceTest { } @Nested - @Accepts({ "Contact:R(Read)" }) class GetContact { @Test void globalAdmin_withoutAssumedRole_canGetArbitraryContact() { context.define("superuser-alex@hostsharing.net"); - final var givenContactUuid = contactRepo.findContactByOptionalLabelLike("first").get(0).getUuid(); + final var givenContactUuid = contactRepo.findContactByOptionalCaptionLike("first").get(0).getUuid(); RestAssured // @formatter:off .given() @@ -147,16 +147,15 @@ class HsOfficeContactControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "label": "first contact" + "caption": "first contact" } """)); // @formatter:on } @Test - @Accepts({ "Contact:X(Access Control)" }) void normalUser_canNotGetUnrelatedContact() { context.define("superuser-alex@hostsharing.net"); - final var givenContactUuid = contactRepo.findContactByOptionalLabelLike("first").get(0).getUuid(); + final var givenContactUuid = contactRepo.findContactByOptionalCaptionLike("first").get(0).getUuid(); RestAssured // @formatter:off .given() @@ -169,10 +168,9 @@ class HsOfficeContactControllerAcceptanceTest { } @Test - @Accepts({ "Contact:X(Access Control)" }) void contactAdminUser_canGetRelatedContact() { context.define("superuser-alex@hostsharing.net"); - final var givenContactUuid = contactRepo.findContactByOptionalLabelLike("first").get(0).getUuid(); + final var givenContactUuid = contactRepo.findContactByOptionalCaptionLike("first").get(0).getUuid(); RestAssured // @formatter:off .given() @@ -185,16 +183,19 @@ class HsOfficeContactControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "label": "first contact", - "emailAddresses": "contact-admin@firstcontact.example.com", - "phoneNumbers": "+49 123 1234567" + "caption": "first contact", + "emailAddresses": { + "main": "contact-admin@firstcontact.example.com" + }, + "phoneNumbers": { + "phone_office": "+49 123 1234567" + } } """)); // @formatter:on } } @Nested - @Accepts({ "Contact:U(Update)" }) class PatchContact { @Test @@ -209,10 +210,14 @@ class HsOfficeContactControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "label": "Temp patched contact", - "emailAddresses": "patched@example.org", + "caption": "Temp patched contact", + "emailAddresses": { + "main": "patched@example.org" + }, "postalAddress": "Patched Address", - "phoneNumbers": "+01 100 123456" + "phoneNumbers": { + "phone_office": "+01 100 123456" + } } """) .port(port) @@ -222,20 +227,20 @@ class HsOfficeContactControllerAcceptanceTest { .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("label", is("Temp patched contact")) - .body("emailAddresses", is("patched@example.org")) + .body("caption", is("Temp patched contact")) + .body("emailAddresses", is(Map.of("main", "patched@example.org"))) .body("postalAddress", is("Patched Address")) - .body("phoneNumbers", is("+01 100 123456")); + .body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456"))); // @formatter:on // finally, the contact is actually updated context.define("superuser-alex@hostsharing.net"); assertThat(contactRepo.findByUuid(givenContact.getUuid())).isPresent().get() .matches(person -> { - assertThat(person.getLabel()).isEqualTo("Temp patched contact"); - assertThat(person.getEmailAddresses()).isEqualTo("patched@example.org"); + assertThat(person.getCaption()).isEqualTo("Temp patched contact"); + assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org")); assertThat(person.getPostalAddress()).isEqualTo("Patched Address"); - assertThat(person.getPhoneNumbers()).isEqualTo("+01 100 123456"); + assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456")); return true; }); } @@ -252,8 +257,12 @@ class HsOfficeContactControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "emailAddresses": "patched@example.org", - "phoneNumbers": "+01 100 123456" + "emailAddresses": { + "main": "patched@example.org" + }, + "phoneNumbers": { + "phone_office": "+01 100 123456" + } } """) .port(port) @@ -263,19 +272,19 @@ class HsOfficeContactControllerAcceptanceTest { .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("label", is(givenContact.getLabel())) - .body("emailAddresses", is("patched@example.org")) + .body("caption", is(givenContact.getCaption())) + .body("emailAddresses", is(Map.of("main", "patched@example.org"))) .body("postalAddress", is(givenContact.getPostalAddress())) - .body("phoneNumbers", is("+01 100 123456")); + .body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456"))); // @formatter:on // finally, the contact is actually updated assertThat(contactRepo.findByUuid(givenContact.getUuid())).isPresent().get() .matches(person -> { - assertThat(person.getLabel()).isEqualTo(givenContact.getLabel()); - assertThat(person.getEmailAddresses()).isEqualTo("patched@example.org"); + assertThat(person.getCaption()).isEqualTo(givenContact.getCaption()); + assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org")); assertThat(person.getPostalAddress()).isEqualTo(givenContact.getPostalAddress()); - assertThat(person.getPhoneNumbers()).isEqualTo("+01 100 123456"); + assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456")); return true; }); } @@ -283,7 +292,6 @@ class HsOfficeContactControllerAcceptanceTest { } @Nested - @Accepts({ "Contact:D(Delete)" }) class DeleteContact { @Test @@ -301,11 +309,13 @@ class HsOfficeContactControllerAcceptanceTest { .statusCode(204); // @formatter:on // then the given contact is gone - assertThat(contactRepo.findByUuid(givenContact.getUuid())).isEmpty(); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + assertThat(contactRepo.findByUuid(givenContact.getUuid())).isEmpty(); + }).assertSuccessful(); } @Test - @Accepts({ "Contact:X(Access Control)" }) void contactOwner_canDeleteRelatedContact() { final var givenContact = givenSomeTemporaryContactCreatedBy("selfregistered-test-user@hostsharing.org"); @@ -319,11 +329,13 @@ class HsOfficeContactControllerAcceptanceTest { .statusCode(204); // @formatter:on // then the given contact is still there - assertThat(contactRepo.findByUuid(givenContact.getUuid())).isEmpty(); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + assertThat(contactRepo.findByUuid(givenContact.getUuid())).isEmpty(); + }).assertSuccessful(); } @Test - @Accepts({ "Contact:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedContact() { context.define("superuser-alex@hostsharing.net"); final var givenContact = givenSomeTemporaryContactCreatedBy("selfregistered-test-user@hostsharing.org"); @@ -343,15 +355,15 @@ class HsOfficeContactControllerAcceptanceTest { } } - private HsOfficeContactEntity givenSomeTemporaryContactCreatedBy(final String creatingUser) { + private HsOfficeContactRbacEntity givenSomeTemporaryContactCreatedBy(final String creatingUser) { return jpaAttempt.transacted(() -> { context.define(creatingUser); - final var newContact = HsOfficeContactEntity.builder() + final var newContact = HsOfficeContactRbacEntity.builder() .uuid(UUID.randomUUID()) - .label("Temp from " + Context.getCallerMethodNameFromStackFrame(1) ) - .emailAddresses(RandomStringUtils.randomAlphabetic(10) + "@example.org") + .caption("Temp from " + Context.getCallerMethodNameFromStackFrame(1) ) + .emailAddresses(Map.of("main", RandomStringUtils.randomAlphabetic(10) + "@example.org")) .postalAddress("Postal Address " + RandomStringUtils.randomAlphabetic(10)) - .phoneNumbers("+01 200 " + RandomStringUtils.randomNumeric(8)) + .phoneNumbers(Map.of("phone_office", "+01 200 " + RandomStringUtils.randomNumeric(8))) .build(); return contactRepo.save(newContact); @@ -363,7 +375,7 @@ class HsOfficeContactControllerAcceptanceTest { void cleanup() { jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net", null); - em.createQuery("DELETE FROM HsOfficeContactEntity c WHERE c.label LIKE 'Temp %'").executeUpdate(); + em.createQuery("DELETE FROM HsOfficeContactRbacEntity c WHERE c.caption LIKE 'Temp %'").executeUpdate(); }).assertSuccessful(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcherUnitTest.java deleted file mode 100644 index f8a45070..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcherUnitTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import net.hostsharing.test.PatchUnitTestBase; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource; -import org.junit.jupiter.api.TestInstance; - -import java.util.UUID; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; - -@TestInstance(PER_CLASS) -class HsOfficeContactEntityPatcherUnitTest extends PatchUnitTestBase< - HsOfficeContactPatchResource, - HsOfficeContactEntity - > { - - private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID(); - - @Override - protected HsOfficeContactEntity newInitialEntity() { - final var entity = new HsOfficeContactEntity(); - entity.setUuid(INITIAL_CONTACT_UUID); - entity.setLabel("initial label"); - entity.setEmailAddresses("initial@example.org"); - entity.setPhoneNumbers("initial postal address"); - entity.setPostalAddress("+01 100 123456789"); - return entity; - } - - @Override - protected HsOfficeContactPatchResource newPatchResource() { - return new HsOfficeContactPatchResource(); - } - - @Override - protected HsOfficeContactEntityPatch createPatcher(final HsOfficeContactEntity entity) { - return new HsOfficeContactEntityPatch(entity); - } - - @Override - protected Stream propertyTestDescriptors() { - return Stream.of( - new JsonNullableProperty<>( - "label", - HsOfficeContactPatchResource::setLabel, - "patched label", - HsOfficeContactEntity::setLabel), - new JsonNullableProperty<>( - "emailAddresses", - HsOfficeContactPatchResource::setEmailAddresses, - "patched trade name", - HsOfficeContactEntity::setEmailAddresses), - new JsonNullableProperty<>( - "phoneNumbers", - HsOfficeContactPatchResource::setPhoneNumbers, - "patched family name", - HsOfficeContactEntity::setPhoneNumbers), - new JsonNullableProperty<>( - "patched given name", - HsOfficeContactPatchResource::setPostalAddress, - "patched given name", - HsOfficeContactEntity::setPostalAddress) - ); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java deleted file mode 100644 index 8f779b5b..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class HsOfficeContactEntityUnitTest { - - @Test - void toStringReturnsNullForNullContact() { - final HsOfficeContactEntity givenContact = null; - assertThat("" + givenContact).isEqualTo("null"); - } - - @Test - void toStringReturnsLabel() { - final var givenContact = HsOfficeContactEntity.builder().label("given label").build(); - assertThat("" + givenContact).isEqualTo("contact(label='given label')"); - } - -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java new file mode 100644 index 00000000..95b4eb94 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java @@ -0,0 +1,100 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource; +import org.junit.jupiter.api.TestInstance; + +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; + +@TestInstance(PER_CLASS) +class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase< + HsOfficeContactPatchResource, + HsOfficeContactRbacEntity + > { + + private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID(); + private static final Map PATCH_EMAIL_ADDRESSES = patchMap( + entry("main", "patched@example.com"), + entry("paul", null), + entry("suse", "suse@example.com") + ); + private static final Map PATCHED_EMAIL_ADDRESSES = patchMap( + entry("main", "patched@example.com"), + entry("suse", "suse@example.com"), + entry("mila", "mila@example.com") + ); + + private static final Map PATCH_PHONE_NUMBERS = patchMap( + entry("phone_mobile", null), + entry("phone_private", "+49 40 987654321"), + entry("fax", "+49 40 12345-99") + ); + private static final Map PATCHED_PHONE_NUMBERS = patchMap( + entry("phone_office", "+49 40 12345-00"), + entry("phone_private", "+49 40 987654321"), + entry("fax", "+49 40 12345-99") + ); + + @Override + protected HsOfficeContactRbacEntity newInitialEntity() { + final var entity = new HsOfficeContactRbacEntity(); + entity.setUuid(INITIAL_CONTACT_UUID); + entity.setCaption("initial caption"); + entity.putEmailAddresses(Map.ofEntries( + entry("main", "initial@example.org"), + entry("paul", "paul@example.com"), + entry("mila", "mila@example.com"))); + entity.putPhoneNumbers(Map.ofEntries( + entry("phone_office", "+49 40 12345-00"), + entry("phone_mobile", "+49 1555 1234567"), + entry("fax", "+49 40 12345-90"))); + entity.setPostalAddress("Initialstraße 50\n20000 Hamburg"); + return entity; + } + + @Override + protected HsOfficeContactPatchResource newPatchResource() { + return new HsOfficeContactPatchResource(); + } + + @Override + protected HsOfficeContactEntityPatcher createPatcher(final HsOfficeContactRbacEntity entity) { + return new HsOfficeContactEntityPatcher(entity); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "caption", + HsOfficeContactPatchResource::setCaption, + "patched caption", + HsOfficeContactRbacEntity::setCaption), + new SimpleProperty<>( + "resources", + HsOfficeContactPatchResource::setEmailAddresses, + PATCH_EMAIL_ADDRESSES, + HsOfficeContactRbacEntity::putEmailAddresses, + PATCHED_EMAIL_ADDRESSES) + .notNullable(), + new SimpleProperty<>( + "resources", + HsOfficeContactPatchResource::setPhoneNumbers, + PATCH_PHONE_NUMBERS, + HsOfficeContactRbacEntity::putPhoneNumbers, + PATCHED_PHONE_NUMBERS) + .notNullable(), + new JsonNullableProperty<>( + "patched given name", + HsOfficeContactPatchResource::setPostalAddress, + "patched given name", + HsOfficeContactRbacEntity::setPostalAddress) + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java similarity index 62% rename from src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java rename to src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java index 0308c31d..5eea0091 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepositoryIntegrationTest.java @@ -1,14 +1,12 @@ package net.hostsharing.hsadminng.hs.office.contact; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,18 +21,18 @@ import java.util.Arrays; import java.util.List; import java.util.function.Supplier; -import static net.hostsharing.hsadminng.hs.office.contact.TestHsOfficeContact.hsOfficeContact; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacTestEntity.hsOfficeContact; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRbacRepository contactRepo; @Autowired RawRbacRoleRepository rawRoleRepo; @@ -62,12 +60,12 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { // when - final var result = attempt(em, () -> contactRepo.save( - hsOfficeContact("a new contact", "contact-admin@www.example.com"))); + final var result = attempt(em, () -> toCleanup(contactRepo.save( + hsOfficeContact("a new contact", "contact-admin@www.example.com")))); // then result.assertSuccessful(); - assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeContactEntity::getUuid).isNotNull(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeContactRbacEntity::getUuid).isNotNull(); assertThatContactIsPersisted(result.returnedValue()); assertThat(contactRepo.count()).isEqualTo(count + 1); } @@ -79,12 +77,12 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { final var count = contactRepo.count(); // when - final var result = attempt(em, () -> contactRepo.save( - hsOfficeContact("another new contact", "another-new-contact@example.com"))); + final var result = attempt(em, () -> toCleanup(contactRepo.save( + hsOfficeContact("another new contact", "another-new-contact@example.com")))); // then result.assertSuccessful(); - assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeContactEntity::getUuid).isNotNull(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeContactRbacEntity::getUuid).isNotNull(); assertThatContactIsPersisted(result.returnedValue()); assertThat(contactRepo.count()).isEqualTo(count + 1); } @@ -93,39 +91,38 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("selfregistered-user-drew@hostsharing.org"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); // when - attempt(em, () -> contactRepo.save( - hsOfficeContact("another new contact", "another-new-contact@example.com")) + attempt(em, () -> toCleanup(contactRepo.save( + hsOfficeContact("another new contact", "another-new-contact@example.com"))) ).assumeSuccessful(); // then final var roles = rawRoleRepo.findAll(); - assertThat(roleNamesOf(roles)).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(roles)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_office_contact#anothernewcontact.owner", - "hs_office_contact#anothernewcontact.admin", - "hs_office_contact#anothernewcontact.tenant", - "hs_office_contact#anothernewcontact.guest" + "hs_office_contact#anothernewcontact:OWNER", + "hs_office_contact#anothernewcontact:ADMIN", + "hs_office_contact#anothernewcontact:REFERRER" )); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, - "{ grant role hs_office_contact#anothernewcontact.owner to role global#global.admin by system and assume }", - "{ grant perm edit on hs_office_contact#anothernewcontact to role hs_office_contact#anothernewcontact.admin by system and assume }", - "{ grant role hs_office_contact#anothernewcontact.tenant to role hs_office_contact#anothernewcontact.admin by system and assume }", - "{ grant perm * on hs_office_contact#anothernewcontact to role hs_office_contact#anothernewcontact.owner by system and assume }", - "{ grant role hs_office_contact#anothernewcontact.admin to role hs_office_contact#anothernewcontact.owner by system and assume }", - "{ grant perm view on hs_office_contact#anothernewcontact to role hs_office_contact#anothernewcontact.guest by system and assume }", - "{ grant role hs_office_contact#anothernewcontact.guest to role hs_office_contact#anothernewcontact.tenant by system and assume }", - "{ grant role hs_office_contact#anothernewcontact.owner to user selfregistered-user-drew@hostsharing.org by global#global.admin and assume }" + "{ grant role:hs_office_contact#anothernewcontact:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant perm:hs_office_contact#anothernewcontact:UPDATE to role:hs_office_contact#anothernewcontact:ADMIN by system and assume }", + "{ grant role:hs_office_contact#anothernewcontact:OWNER to user:selfregistered-user-drew@hostsharing.org by hs_office_contact#anothernewcontact:OWNER and assume }", + "{ grant perm:hs_office_contact#anothernewcontact:DELETE to role:hs_office_contact#anothernewcontact:OWNER by system and assume }", + "{ grant role:hs_office_contact#anothernewcontact:ADMIN to role:hs_office_contact#anothernewcontact:OWNER by system and assume }", + + "{ grant perm:hs_office_contact#anothernewcontact:SELECT to role:hs_office_contact#anothernewcontact:REFERRER by system and assume }", + "{ grant role:hs_office_contact#anothernewcontact:REFERRER to role:hs_office_contact#anothernewcontact:ADMIN by system and assume }" )); } - private void assertThatContactIsPersisted(final HsOfficeContactEntity saved) { + private void assertThatContactIsPersisted(final HsOfficeContactRbacEntity saved) { final var found = contactRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -138,7 +135,7 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net"); // when - final var result = contactRepo.findContactByOptionalLabelLike(null); + final var result = contactRepo.findContactByOptionalCaptionLike(null); // then allTheseContactsAreReturned(result, "first contact", "second contact", "third contact"); @@ -151,15 +148,15 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { // when: context("selfregistered-user-drew@hostsharing.org"); - final var result = contactRepo.findContactByOptionalLabelLike(null); + final var result = contactRepo.findContactByOptionalCaptionLike(null); // then: - exactlyTheseContactsAreReturned(result, givenContact.getLabel()); + exactlyTheseContactsAreReturned(result, givenContact.getCaption()); } } @Nested - class FindByLabelLike { + class FindByCaptionLike { @Test public void globalAdmin_withoutAssumedRole_canViewAllContacts() { @@ -167,7 +164,7 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net", null); // when - final var result = contactRepo.findContactByOptionalLabelLike("second"); + final var result = contactRepo.findContactByOptionalCaptionLike("second"); // then exactlyTheseContactsAreReturned(result, "second contact"); @@ -180,10 +177,10 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { // when: context("selfregistered-user-drew@hostsharing.org"); - final var result = contactRepo.findContactByOptionalLabelLike(givenContact.getLabel()); + final var result = contactRepo.findContactByOptionalCaptionLike(givenContact.getCaption()); // then: - exactlyTheseContactsAreReturned(result, givenContact.getLabel()); + exactlyTheseContactsAreReturned(result, givenContact.getCaption()); } } @@ -206,7 +203,7 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { result.assertSuccessful(); assertThat(jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", null); - return contactRepo.findContactByOptionalLabelLike(givenContact.getLabel()); + return contactRepo.findContactByOptionalCaptionLike(givenContact.getCaption()); }).assertSuccessful().returnedValue()).hasSize(0); } @@ -225,7 +222,7 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { result.assertSuccessful(); assertThat(jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", null); - return contactRepo.findContactByOptionalLabelLike(givenContact.getLabel()); + return contactRepo.findContactByOptionalCaptionLike(givenContact.getCaption()); }).assertSuccessful().returnedValue()).hasSize(0); } @@ -233,8 +230,8 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { public void deletingAContactAlsoDeletesRelatedRolesAndGrants() { // given context("selfregistered-user-drew@hostsharing.org", null); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); final var givenContact = givenSomeTemporaryContact("selfregistered-user-drew@hostsharing.org"); // when @@ -246,10 +243,10 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( initialRoleNames )); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from( initialGrantNames )); } @@ -259,9 +256,8 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'caption' + from tx_journal_v where targettable = 'hs_office_contact'; """); @@ -270,31 +266,21 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating contact test-data first contact, hs_office_contact, INSERT]", - "[creating contact test-data second contact, hs_office_contact, INSERT]"); + "[creating contact test-data, hs_office_contact, INSERT, first contact]", + "[creating contact test-data, hs_office_contact, INSERT, second contact]", + "[creating contact test-data, hs_office_contact, INSERT, third contact]"); } - private HsOfficeContactEntity givenSomeTemporaryContact( + private HsOfficeContactRbacEntity givenSomeTemporaryContact( final String createdByUser, - Supplier entitySupplier) { + Supplier entitySupplier) { return jpaAttempt.transacted(() -> { context(createdByUser); - return contactRepo.save(entitySupplier.get()); + return toCleanup(contactRepo.save(entitySupplier.get())); }).assumeSuccessful().returnedValue(); } - @BeforeEach - @AfterEach - void cleanup() { - context("superuser-alex@hostsharing.net", null); - final var result = contactRepo.findContactByOptionalLabelLike("some temporary contact"); - result.forEach(tempPerson -> { - System.out.println("DELETING temporary contact: " + tempPerson.getLabel()); - contactRepo.deleteByUuid(tempPerson.getUuid()); - }); - } - - private HsOfficeContactEntity givenSomeTemporaryContact(final String createdByUser) { + private HsOfficeContactRbacEntity givenSomeTemporaryContact(final String createdByUser) { final var random = RandomStringUtils.randomAlphabetic(12); return givenSomeTemporaryContact(createdByUser, () -> hsOfficeContact( @@ -302,15 +288,15 @@ class HsOfficeContactRepositoryIntegrationTest extends ContextBasedTest { "some-temporary-contact" + random + "@example.com")); } - void exactlyTheseContactsAreReturned(final List actualResult, final String... contactLabels) { + void exactlyTheseContactsAreReturned(final List actualResult, final String... contactCaptions) { assertThat(actualResult) - .extracting(HsOfficeContactEntity::getLabel) - .containsExactlyInAnyOrder(contactLabels); + .extracting(HsOfficeContactRbacEntity::getCaption) + .containsExactlyInAnyOrder(contactCaptions); } - void allTheseContactsAreReturned(final List actualResult, final String... contactLabels) { + void allTheseContactsAreReturned(final List actualResult, final String... contactCaptions) { assertThat(actualResult) - .extracting(HsOfficeContactEntity::getLabel) - .contains(contactLabels); + .extracting(HsOfficeContactRbacEntity::getCaption) + .contains(contactCaptions); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java new file mode 100644 index 00000000..ba96f31b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java @@ -0,0 +1,16 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import java.util.Map; + +public class HsOfficeContactRbacTestEntity { + + public static final HsOfficeContactRbacEntity TEST_RBAC_CONTACT = hsOfficeContact("some contact", "some-contact@example.com"); + + static public HsOfficeContactRbacEntity hsOfficeContact(final String caption, final String emailAddr) { + return HsOfficeContactRbacEntity.builder() + .caption(caption) + .postalAddress("address of " + caption) + .emailAddresses(Map.of("main", emailAddr)) + .build(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java new file mode 100644 index 00000000..d8cdfe1b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java @@ -0,0 +1,16 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import java.util.Map; + +public class HsOfficeContactRealTestEntity { + + public static final HsOfficeContactRealEntity TEST_REAL_CONTACT = hsOfficeContact("some contact", "some-contact@example.com"); + + static public HsOfficeContactRealEntity hsOfficeContact(final String caption, final String emailAddr) { + return HsOfficeContactRealEntity.builder() + .caption(caption) + .postalAddress("address of " + caption) + .emailAddresses(Map.of("main", emailAddr)) + .build(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactUnitTest.java new file mode 100644 index 00000000..94f8e0b8 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactUnitTest.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.hs.office.contact; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeContactUnitTest { + + @Test + void toStringReturnsNullForNullContact() { + final HsOfficeContactRbacEntity givenContact = null; + assertThat("" + givenContact).isEqualTo("null"); + } + + @Test + void toStringReturnsCaption() { + final var givenContact = HsOfficeContactRbacEntity.builder().caption("given caption").build(); + assertThat("" + givenContact).isEqualTo("contact(caption='given caption')"); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/TestHsOfficeContact.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/TestHsOfficeContact.java deleted file mode 100644 index b42ef8e5..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/TestHsOfficeContact.java +++ /dev/null @@ -1,15 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.contact; - - -public class TestHsOfficeContact { - - public static final HsOfficeContactEntity TEST_CONTACT = hsOfficeContact("some contact", "some-contact@example.com"); - - static public HsOfficeContactEntity hsOfficeContact(final String label, final String emailAddr) { - return HsOfficeContactEntity.builder() - .label(label) - .postalAddress("address of " + label) - .emailAddresses(emailAddr) - .build(); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java index b5dfa429..cb2b937b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java @@ -5,8 +5,8 @@ import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -18,11 +18,13 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType.DEPOSIT; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.startsWith; @@ -32,7 +34,7 @@ import static org.hamcrest.Matchers.startsWith; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { +class HsOfficeCoopAssetsTransactionControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort Integer port; @@ -53,7 +55,6 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "CoopAssetsTransaction:F(Find)" }) class ListCoopAssetsTransactions { @Test @@ -68,7 +69,7 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { .then().log().all().assertThat() .statusCode(200) .contentType("application/json") - .body("", hasSize(9)); // @formatter:on + .body("", hasSize(12)); // @formatter:on } @Test @@ -103,11 +104,32 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { "comment": "partial disbursal" }, { - "transactionType": "ADJUSTMENT", + "transactionType": "DEPOSIT", "assetValue": 128.00, "valueDate": "2022-10-20", "reference": "ref 1000202-3", - "comment": "some adjustment" + "comment": "some loss", + "adjustmentAssetTx": { + "transactionType": "ADJUSTMENT", + "assetValue": -128.00, + "valueDate": "2022-10-21", + "reference": "ref 1000202-3", + "comment": "some adjustment" + } + }, + { + "transactionType": "ADJUSTMENT", + "assetValue": -128.00, + "valueDate": "2022-10-21", + "reference": "ref 1000202-3", + "comment": "some adjustment", + "adjustedAssetTx": { + "transactionType": "DEPOSIT", + "assetValue": 128.00, + "valueDate": "2022-10-20", + "reference": "ref 1000202-3", + "comment": "some loss" + } } ] """)); // @formatter:on @@ -144,7 +166,6 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { } @Nested - @Accepts({ "CoopAssetsTransaction:C(Create)" }) class AddCoopAssetsTransaction { @Test @@ -187,9 +208,77 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { .extract().header("Location"); // @formatter:on // finally, the new coopAssetsTransaction can be accessed under the generated UUID - final var newUserUuid = UUID.fromString( + final var newAssetTxUuid = UUID.fromString( location.substring(location.lastIndexOf('/') + 1)); - assertThat(newUserUuid).isNotNull(); + assertThat(newAssetTxUuid).isNotNull(); + } + + @Test + void globalAdmin_canAddCoopAssetsAdjustmentTransaction() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); + final var givenTransaction = jpaAttempt.transacted(() -> { + // TODO.impl: introduce something like transactedAsSuperuser / transactedAs("...", ...) + context.define("superuser-alex@hostsharing.net"); + return coopAssetsTransactionRepo.save(HsOfficeCoopAssetsTransactionEntity.builder() + .transactionType(DEPOSIT) + .valueDate(LocalDate.of(2022, 10, 20)) + .membership(givenMembership) + .assetValue(new BigDecimal("256.00")) + .reference("test ref") + .build()); + }).assertSuccessful().assertNotNull().returnedValue(); + toCleanup(HsOfficeCoopAssetsTransactionEntity.class, givenTransaction.getUuid()); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "membershipUuid": "%s", + "transactionType": "ADJUSTMENT", + "assetValue": %s, + "valueDate": "2022-10-30", + "reference": "test ref adjustment", + "comment": "some coop assets adjustment transaction", + "reverseEntryUuid": "%s" + } + """.formatted( + givenMembership.getUuid(), + givenTransaction.getAssetValue().negate().toString(), + givenTransaction.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/coopassetstransactions") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("", lenientlyEquals(""" + { + "transactionType": "ADJUSTMENT", + "assetValue": -256.00, + "valueDate": "2022-10-30", + "reference": "test ref adjustment", + "comment": "some coop assets adjustment transaction", + "adjustedAssetTx": { + "transactionType": "DEPOSIT", + "assetValue": 256.00, + "valueDate": "2022-10-20", + "reference": "test ref" + } + } + """.formatted(givenTransaction.getUuid()))) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on + + // finally, the new coopAssetsTransaction can be accessed under the generated UUID + final var newAssetTxUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newAssetTxUuid).isNotNull(); + toCleanup(HsOfficeCoopAssetsTransactionEntity.class, newAssetTxUuid); } @Test @@ -198,7 +287,7 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) @@ -229,7 +318,6 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { } @Nested - @Accepts({ "CoopAssetTransaction:R(Read)" }) class GetCoopAssetTransaction { @Test @@ -256,7 +344,6 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { } @Test - @Accepts({ "CoopAssetTransaction:X(Access Control)" }) void normalUser_canNotGetUnrelatedCoopAssetTransaction() { context.define("superuser-alex@hostsharing.net"); final var givenCoopAssetTransactionUuid = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( @@ -274,8 +361,7 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { } @Test - @Accepts({ "CoopAssetTransaction:X(Access Control)" }) - void contactAdminUser_canGetRelatedCoopAssetTransaction() { + void partnerPersonUser_canGetRelatedCoopAssetTransaction() { context.define("superuser-alex@hostsharing.net"); final var givenCoopAssetTransactionUuid = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( null, @@ -284,7 +370,7 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { RestAssured // @formatter:off .given() - .header("current-user", "contact-admin@firstcontact.example.com") + .header("current-user", "person-FirstGmbH@example.com") .port(port) .when() .get("http://localhost/api/hs/office/coopassetstransactions/" + givenCoopAssetTransactionUuid) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java index 043404dd..8176df09 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.Mapper; -import net.hostsharing.test.JsonBuilder; +import net.hostsharing.hsadminng.rbac.test.JsonBuilder; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; @@ -15,7 +15,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.util.UUID; import java.util.function.Function; -import static net.hostsharing.test.JsonBuilder.jsonObject; +import static net.hostsharing.hsadminng.rbac.test.JsonBuilder.jsonObject; import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -124,7 +124,7 @@ class HsOfficeCoopAssetsTransactionControllerRestTest { .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is(testCase.expectedErrorMessage))); + .andExpect(jsonPath("message", is("ERROR: [400] " + testCase.expectedErrorMessage))); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java index d93aa90f..aada2552 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java @@ -16,34 +16,56 @@ class HsOfficeCoopAssetsTransactionEntityUnitTest { .valueDate(LocalDate.parse("2020-01-01")) .transactionType(HsOfficeCoopAssetsTransactionType.DEPOSIT) .assetValue(new BigDecimal("128.00")) + .comment("some comment") .build(); + + + final HsOfficeCoopAssetsTransactionEntity givenCoopAssetAdjustmentTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .membership(TEST_MEMBERSHIP) + .reference("some-ref") + .valueDate(LocalDate.parse("2020-01-15")) + .transactionType(HsOfficeCoopAssetsTransactionType.ADJUSTMENT) + .assetValue(new BigDecimal("-128.00")) + .comment("some comment") + .adjustedAssetTx(givenCoopAssetTransaction) + .build(); + final HsOfficeCoopAssetsTransactionEntity givenEmptyCoopAssetsTransaction = HsOfficeCoopAssetsTransactionEntity.builder().build(); @Test - void toStringContainsAlmostAllPropertiesAccount() { + void toStringContainsAllNonNullProperties() { final var result = givenCoopAssetTransaction.toString(); - assertThat(result).isEqualTo("CoopAssetsTransaction(1000101, 2020-01-01, DEPOSIT, 128.00, some-ref)"); + assertThat(result).isEqualTo("CoopAssetsTransaction(M-1000101: 2020-01-01, DEPOSIT, 128.00, some-ref, some comment)"); + } + + @Test + void toStringWithReverseEntryContainsReverseEntry() { + givenCoopAssetTransaction.setAdjustedAssetTx(givenCoopAssetAdjustmentTransaction); + + final var result = givenCoopAssetTransaction.toString(); + + assertThat(result).isEqualTo("CoopAssetsTransaction(M-1000101: 2020-01-01, DEPOSIT, 128.00, some-ref, some comment, M-1000101:ADJ:-128.00)"); } @Test void toShortStringContainsOnlyMemberNumberSuffixAndSharesCountOnly() { final var result = givenCoopAssetTransaction.toShortString(); - assertThat(result).isEqualTo("1000101+128.00"); + assertThat(result).isEqualTo("M-1000101:DEP:+128.00"); } @Test void toStringWithEmptyTransactionDoesNotThrowException() { final var result = givenEmptyCoopAssetsTransaction.toString(); - assertThat(result).isEqualTo("CoopAssetsTransaction()"); + assertThat(result).isEqualTo("CoopAssetsTransaction(M-???????: )"); } @Test void toShortStringEmptyTransactionDoesNotThrowException() { final var result = givenEmptyCoopAssetsTransaction.toShortString(); - assertThat(result).isEqualTo("nullnu"); + assertThat(result).isEqualTo("M-???????:nul:+0.00"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java index 89f48402..ad059e16 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java @@ -1,12 +1,12 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -24,14 +24,14 @@ import java.time.LocalDate; import java.util.Arrays; import java.util.List; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; @@ -62,7 +62,7 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase // given context("superuser-alex@hostsharing.net"); final var count = coopAssetsTransactionRepo.count(); - final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); + final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101).load(); // when final var result = attempt(em, () -> { @@ -87,9 +87,8 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() .map(s -> s.replace("hs_office_", "")) .toList(); @@ -108,19 +107,19 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase // then final var all = rawRoleRepo.findAll(); - assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from(initialRoleNames)); // no new roles created - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from(initialRoleNames)); // no new roles created + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) .map(s -> s.replace("hs_office_", "")) .containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, - "{ grant perm view on coopassetstransaction#temprefB to role membership#1000101:....tenant by system and assume }", + "{ grant perm:coopassetstransaction#temprefB:SELECT to role:membership#M-1000101:AGENT by system and assume }", + "{ grant perm:coopassetstransaction#temprefB:UPDATE to role:membership#M-1000101:ADMIN by system and assume }", null)); } private void assertThatCoopAssetsTransactionIsPersisted(final HsOfficeCoopAssetsTransactionEntity saved) { final var found = coopAssetsTransactionRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(HsOfficeCoopAssetsTransactionEntity::toString).isEqualTo(saved.toString()); } } @@ -128,7 +127,7 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase class FindAllCoopAssetsTransactions { @Test - public void globalAdmin_anViewAllCoopAssetsTransactions() { + public void globalAdmin_canViewAllCoopAssetsTransactions() { // given context("superuser-alex@hostsharing.net"); @@ -139,19 +138,22 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase null); // then - allTheseCoopAssetsTransactionsAreReturned( + exactlyTheseCoopAssetsTransactionsAreReturned( result, - "CoopAssetsTransaction(1000101, 2010-03-15, DEPOSIT, 320.00, ref 1000101-1, initial deposit)", - "CoopAssetsTransaction(1000101, 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", - "CoopAssetsTransaction(1000101, 2022-10-20, ADJUSTMENT, 128.00, ref 1000101-3, some adjustment)", + "CoopAssetsTransaction(M-1000101: 2010-03-15, DEPOSIT, 320.00, ref 1000101-1, initial deposit)", + "CoopAssetsTransaction(M-1000101: 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", + "CoopAssetsTransaction(M-1000101: 2022-10-20, DEPOSIT, 128.00, ref 1000101-3, some loss, M-1000101:ADJ:-128.00)", + "CoopAssetsTransaction(M-1000101: 2022-10-21, ADJUSTMENT, -128.00, ref 1000101-3, some adjustment, M-1000101:DEP:+128.00)", - "CoopAssetsTransaction(1000202, 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", - "CoopAssetsTransaction(1000202, 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", - "CoopAssetsTransaction(1000202, 2022-10-20, ADJUSTMENT, 128.00, ref 1000202-3, some adjustment)", + "CoopAssetsTransaction(M-1000202: 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", + "CoopAssetsTransaction(M-1000202: 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", + "CoopAssetsTransaction(M-1000202: 2022-10-20, DEPOSIT, 128.00, ref 1000202-3, some loss, M-1000202:ADJ:-128.00)", + "CoopAssetsTransaction(M-1000202: 2022-10-21, ADJUSTMENT, -128.00, ref 1000202-3, some adjustment, M-1000202:DEP:+128.00)", - "CoopAssetsTransaction(1000303, 2010-03-15, DEPOSIT, 320.00, ref 1000303-1, initial deposit)", - "CoopAssetsTransaction(1000303, 2021-09-01, DISBURSAL, -128.00, ref 1000303-2, partial disbursal)", - "CoopAssetsTransaction(1000303, 2022-10-20, ADJUSTMENT, 128.00, ref 1000303-3, some adjustment)"); + "CoopAssetsTransaction(M-1000303: 2010-03-15, DEPOSIT, 320.00, ref 1000303-1, initial deposit)", + "CoopAssetsTransaction(M-1000303: 2021-09-01, DISBURSAL, -128.00, ref 1000303-2, partial disbursal)", + "CoopAssetsTransaction(M-1000303: 2022-10-20, DEPOSIT, 128.00, ref 1000303-3, some loss, M-1000303:ADJ:-128.00)", + "CoopAssetsTransaction(M-1000303: 2022-10-21, ADJUSTMENT, -128.00, ref 1000303-3, some adjustment, M-1000303:DEP:+128.00)"); } @Test @@ -167,11 +169,12 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase null); // then - allTheseCoopAssetsTransactionsAreReturned( + exactlyTheseCoopAssetsTransactionsAreReturned( result, - "CoopAssetsTransaction(1000202, 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", - "CoopAssetsTransaction(1000202, 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", - "CoopAssetsTransaction(1000202, 2022-10-20, ADJUSTMENT, 128.00, ref 1000202-3, some adjustment)"); + "CoopAssetsTransaction(M-1000202: 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", + "CoopAssetsTransaction(M-1000202: 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", + "CoopAssetsTransaction(M-1000202: 2022-10-20, DEPOSIT, 128.00, ref 1000202-3, some loss, M-1000202:ADJ:-128.00)", + "CoopAssetsTransaction(M-1000202: 2022-10-21, ADJUSTMENT, -128.00, ref 1000202-3, some adjustment, M-1000202:DEP:+128.00)"); } @Test @@ -189,13 +192,13 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase // then allTheseCoopAssetsTransactionsAreReturned( result, - "CoopAssetsTransaction(1000202, 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)"); + "CoopAssetsTransaction(M-1000202: 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)"); } @Test - public void normalUser_canViewOnlyRelatedCoopAssetsTransactions() { + public void partnerPersonAdmin_canViewRelatedCoopAssetsTransactions() { // given: - context("superuser-alex@hostsharing.net", "hs_office_partner#10001:FirstGmbH-firstcontact.admin"); + context("superuser-alex@hostsharing.net", "hs_office_person#FirstGmbH:ADMIN"); // when: final var result = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( @@ -206,9 +209,10 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase // then: exactlyTheseCoopAssetsTransactionsAreReturned( result, - "CoopAssetsTransaction(1000101, 2010-03-15, DEPOSIT, 320.00, ref 1000101-1, initial deposit)", - "CoopAssetsTransaction(1000101, 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", - "CoopAssetsTransaction(1000101, 2022-10-20, ADJUSTMENT, 128.00, ref 1000101-3, some adjustment)"); + "CoopAssetsTransaction(M-1000101: 2010-03-15, DEPOSIT, 320.00, ref 1000101-1, initial deposit)", + "CoopAssetsTransaction(M-1000101: 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", + "CoopAssetsTransaction(M-1000101: 2022-10-20, DEPOSIT, 128.00, ref 1000101-3, some loss, M-1000101:ADJ:-128.00)", + "CoopAssetsTransaction(M-1000101: 2022-10-21, ADJUSTMENT, -128.00, ref 1000101-3, some adjustment, M-1000101:DEP:+128.00)"); } } @@ -216,9 +220,8 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'reference' + from tx_journal_v where targettable = 'hs_office_coopassetstransaction'; """); @@ -227,8 +230,18 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating coopAssetsTransaction test-data 1000101, hs_office_coopassetstransaction, INSERT]", - "[creating coopAssetsTransaction test-data 1000202, hs_office_coopassetstransaction, INSERT]"); + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000101-1]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000101-2]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000101-3]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000101-3]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000202-1]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000202-2]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000202-3]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000202-3]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000303-1]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000303-2]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000303-3]", + "[creating coopAssetsTransaction test-data, hs_office_coopassetstransaction, INSERT, ref 1000303-3]"); } @BeforeEach diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerAcceptanceTest.java index 787fe467..bdd9a34a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerAcceptanceTest.java @@ -5,8 +5,8 @@ import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -18,18 +18,19 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; + import java.time.LocalDate; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.startsWith; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {HsadminNgApplication.class, JpaAttempt.class}) @Transactional -class HsOfficeCoopSharesTransactionControllerAcceptanceTest { +class HsOfficeCoopSharesTransactionControllerAcceptanceTest extends ContextBasedTestWithCleanup { @Autowired Context context; @@ -61,14 +62,21 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { } @Nested - @Accepts({"CoopSharesTransaction:F(Find)"}) class ListCoopSharesTransactions { @Test void globalAdmin_canViewAllCoopSharesTransactions() { RestAssured // @formatter:off - .given().header("current-user", "superuser-alex@hostsharing.net").port(port).when().get("http://localhost/api/hs/office/coopsharestransactions").then().log().all().assertThat().statusCode(200).contentType("application/json").body("", hasSize(9)); // @formatter:on + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/coopsharestransactions") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", hasSize(12)); // @formatter:on } @Test @@ -94,12 +102,33 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { "reference": "ref 1000202-2", "comment": "cancelling some" }, + { + "transactionType": "SUBSCRIPTION", + "shareCount": 2, + "valueDate": "2022-10-20", + "reference": "ref 1000202-3", + "comment": "some subscription", + "adjustmentShareTx": { + "transactionType": "ADJUSTMENT", + "shareCount": -2, + "valueDate": "2022-10-21", + "reference": "ref 1000202-4", + "comment": "some adjustment" + } + }, { "transactionType": "ADJUSTMENT", - "shareCount": 2, - "valueDate": "2022-10-20", - "reference": "ref 1000202-3", - "comment": "some adjustment" + "shareCount": -2, + "valueDate": "2022-10-21", + "reference": "ref 1000202-4", + "comment": "some adjustment", + "adjustedShareTx": { + "transactionType": "SUBSCRIPTION", + "shareCount": 2, + "valueDate": "2022-10-20", + "reference": "ref 1000202-3", + "comment": "some subscription" + } } ] """)); // @formatter:on @@ -128,7 +157,6 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { } @Nested - @Accepts({"CoopSharesTransaction:C(Create)"}) class AddCoopSharesTransaction { @Test @@ -158,8 +186,76 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { """)).header("Location", startsWith("http://localhost")).extract().header("Location"); // @formatter:on // finally, the new coopSharesTransaction can be accessed under the generated UUID - final var newUserUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1)); - assertThat(newUserUuid).isNotNull(); + final var newShareTxUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1)); + assertThat(newShareTxUuid).isNotNull(); + } + + @Test + void globalAdmin_canAddCoopSharesAdjustmentTransaction() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); + final var givenTransaction = jpaAttempt.transacted(() -> { + // TODO.impl: introduce something like transactedAsSuperuser / transactedAs("...", ...) + context.define("superuser-alex@hostsharing.net"); + return coopSharesTransactionRepo.save(HsOfficeCoopSharesTransactionEntity.builder() + .transactionType(HsOfficeCoopSharesTransactionType.SUBSCRIPTION) + .valueDate(LocalDate.of(2022, 10, 20)) + .membership(givenMembership) + .shareCount(13) + .reference("test ref") + .build()); + }).assertSuccessful().assertNotNull().returnedValue(); + toCleanup(HsOfficeCoopSharesTransactionEntity.class, givenTransaction.getUuid()); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "membershipUuid": "%s", + "transactionType": "ADJUSTMENT", + "shareCount": %s, + "valueDate": "2022-10-30", + "reference": "test ref adjustment", + "comment": "some coop shares adjustment transaction", + "adjustedShareTxUuid": "%s" + } + """.formatted( + givenMembership.getUuid(), + -givenTransaction.getShareCount(), + givenTransaction.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/coopsharestransactions") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("", lenientlyEquals(""" + { + "transactionType": "ADJUSTMENT", + "shareCount": -13, + "valueDate": "2022-10-30", + "reference": "test ref adjustment", + "comment": "some coop shares adjustment transaction", + "adjustedShareTx": { + "transactionType": "SUBSCRIPTION", + "shareCount": 13, + "valueDate": "2022-10-20", + "reference": "test ref" + } + } + """)) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on + + // finally, the new coopAssetsTransaction can be accessed under the generated UUID + final var newShareTxUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newShareTxUuid).isNotNull(); + toCleanup(HsOfficeCoopSharesTransactionEntity.class, newShareTxUuid); } @Test @@ -168,7 +264,7 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given().header("current-user", "superuser-alex@hostsharing.net").contentType(ContentType.JSON).body(""" { "membershipUuid": "%s", @@ -189,7 +285,6 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { } @Nested - @Accepts({"CoopShareTransaction:R(Read)"}) class GetCoopShareTransaction { @Test @@ -206,7 +301,6 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { } @Test - @Accepts({"CoopShareTransaction:X(Access Control)"}) void normalUser_canNotGetUnrelatedCoopShareTransaction() { context.define("superuser-alex@hostsharing.net"); final var givenCoopShareTransactionUuid = coopSharesTransactionRepo.findCoopSharesTransactionByOptionalMembershipUuidAndDateRange(null, LocalDate.of(2010, 3, 15), LocalDate.of(2010, 3, 15)).get(0).getUuid(); @@ -216,18 +310,27 @@ class HsOfficeCoopSharesTransactionControllerAcceptanceTest { } @Test - @Accepts({"CoopShareTransaction:X(Access Control)"}) - void contactAdminUser_canGetRelatedCoopShareTransaction() { + void partnerPersonUser_canGetRelatedCoopShareTransaction() { context.define("superuser-alex@hostsharing.net"); final var givenCoopShareTransactionUuid = coopSharesTransactionRepo.findCoopSharesTransactionByOptionalMembershipUuidAndDateRange(null, LocalDate.of(2010, 3, 15), LocalDate.of(2010, 3, 15)).get(0).getUuid(); RestAssured // @formatter:off - .given().header("current-user", "contact-admin@firstcontact.example.com").port(port).when().get("http://localhost/api/hs/office/coopsharestransactions/" + givenCoopShareTransactionUuid).then().log().body().assertThat().statusCode(200).contentType("application/json").body("", lenientlyEquals(""" - { - "transactionType": "SUBSCRIPTION", - "shareCount": 4 - } - """)); // @formatter:on + .given() + .header("current-user", "person-FirstGmbH@example.com") + .port(port) + .when() + .get("http://localhost/api/hs/office/coopsharestransactions/" + givenCoopShareTransactionUuid) + .then() + .log().body() + .assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "transactionType": "SUBSCRIPTION", + "shareCount": 4 + } + """)); // @formatter:on } } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java index c1b4307b..6c126978 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.office.coopshares; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.Mapper; -import net.hostsharing.test.JsonBuilder; +import net.hostsharing.hsadminng.rbac.test.JsonBuilder; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; @@ -15,7 +15,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.util.UUID; import java.util.function.Function; -import static net.hostsharing.test.JsonBuilder.jsonObject; +import static net.hostsharing.hsadminng.rbac.test.JsonBuilder.jsonObject; import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -120,7 +120,7 @@ class HsOfficeCoopSharesTransactionControllerRestTest { .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is(testCase.expectedErrorMessage))); + .andExpect(jsonPath("message", is("ERROR: [400] " + testCase.expectedErrorMessage))); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java index 0170e1d8..08a2718d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java @@ -15,34 +15,56 @@ class HsOfficeCoopSharesTransactionEntityUnitTest { .valueDate(LocalDate.parse("2020-01-01")) .transactionType(HsOfficeCoopSharesTransactionType.SUBSCRIPTION) .shareCount(4) + .comment("some comment") .build(); + + + final HsOfficeCoopSharesTransactionEntity givenCoopShareAdjustmentTransaction = HsOfficeCoopSharesTransactionEntity.builder() + .membership(TEST_MEMBERSHIP) + .reference("some-ref") + .valueDate(LocalDate.parse("2020-01-15")) + .transactionType(HsOfficeCoopSharesTransactionType.ADJUSTMENT) + .shareCount(-4) + .comment("some comment") + .adjustedShareTx(givenCoopSharesTransaction) + .build(); + final HsOfficeCoopSharesTransactionEntity givenEmptyCoopSharesTransaction = HsOfficeCoopSharesTransactionEntity.builder().build(); @Test - void toStringContainsAlmostAllPropertiesAccount() { + void toStringContainsAllNonNullProperties() { final var result = givenCoopSharesTransaction.toString(); - assertThat(result).isEqualTo("CoopShareTransaction(1000101, 2020-01-01, SUBSCRIPTION, 4, some-ref)"); + assertThat(result).isEqualTo("CoopShareTransaction(M-1000101: 2020-01-01, SUBSCRIPTION, 4, some-ref, some comment)"); } @Test - void toShortStringContainsOnlyMemberNumberAndShareCountOnly() { + void toStringWithReverseEntryContainsReverseEntry() { + givenCoopSharesTransaction.setAdjustedShareTx(givenCoopShareAdjustmentTransaction); + + final var result = givenCoopSharesTransaction.toString(); + + assertThat(result).isEqualTo("CoopShareTransaction(M-1000101: 2020-01-01, SUBSCRIPTION, 4, some-ref, some comment, M-1000101:ADJ:-4)"); + } + + @Test + void toShortStringContainsOnlyAbbreviatedString() { final var result = givenCoopSharesTransaction.toShortString(); - assertThat(result).isEqualTo("M-1000101+4"); + assertThat(result).isEqualTo("M-1000101:SUB:+4"); } @Test void toStringEmptyTransactionDoesNotThrowException() { final var result = givenEmptyCoopSharesTransaction.toString(); - assertThat(result).isEqualTo("CoopShareTransaction(0)"); + assertThat(result).isEqualTo("CoopShareTransaction(null: 0)"); } @Test void toShortStringEmptyTransactionDoesNotThrowException() { final var result = givenEmptyCoopSharesTransaction.toShortString(); - assertThat(result).isEqualTo("M-null+0"); + assertThat(result).isEqualTo("null:nul:+0"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java index 78d0ac7d..db1b0f39 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java @@ -1,12 +1,12 @@ package net.hostsharing.hsadminng.hs.office.coopshares; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -23,14 +23,14 @@ import java.time.LocalDate; import java.util.Arrays; import java.util.List; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeCoopSharesTransactionRepository coopSharesTransactionRepo; @@ -61,7 +61,7 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase // given context("superuser-alex@hostsharing.net"); final var count = coopSharesTransactionRepo.count(); - final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101); + final var givenMembership = membershipRepo.findMembershipByMemberNumber(1000101).load(); // when final var result = attempt(em, () -> { @@ -86,9 +86,8 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() .map(s -> s.replace("hs_office_", "")) .toList(); @@ -107,19 +106,19 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase // then final var all = rawRoleRepo.findAll(); - assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from(initialRoleNames)); // no new roles created - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from(initialRoleNames)); // no new roles created + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) .map(s -> s.replace("hs_office_", "")) .containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, - "{ grant perm view on coopsharestransaction#temprefB to role membership#1000101:....tenant by system and assume }", + "{ grant perm:coopsharestransaction#temprefB:SELECT to role:membership#M-1000101:AGENT by system and assume }", + "{ grant perm:coopsharestransaction#temprefB:UPDATE to role:membership#M-1000101:ADMIN by system and assume }", null)); } private void assertThatCoopSharesTransactionIsPersisted(final HsOfficeCoopSharesTransactionEntity saved) { final var found = coopSharesTransactionRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(HsOfficeCoopSharesTransactionEntity::toString).isEqualTo(saved.toString()); } } @@ -127,7 +126,7 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase class FindAllCoopSharesTransactions { @Test - public void globalAdmin_anViewAllCoopSharesTransactions() { + public void globalAdmin_canViewAllCoopSharesTransactions() { // given context("superuser-alex@hostsharing.net"); @@ -138,19 +137,22 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase null); // then - allTheseCoopSharesTransactionsAreReturned( + exactlyTheseCoopSharesTransactionsAreReturned( result, - "CoopShareTransaction(1000101, 2010-03-15, SUBSCRIPTION, 4, ref 1000101-1, initial subscription)", - "CoopShareTransaction(1000101, 2021-09-01, CANCELLATION, -2, ref 1000101-2, cancelling some)", - "CoopShareTransaction(1000101, 2022-10-20, ADJUSTMENT, 2, ref 1000101-3, some adjustment)", + "CoopShareTransaction(M-1000101: 2010-03-15, SUBSCRIPTION, 4, ref 1000101-1, initial subscription)", + "CoopShareTransaction(M-1000101: 2021-09-01, CANCELLATION, -2, ref 1000101-2, cancelling some)", + "CoopShareTransaction(M-1000101: 2022-10-20, SUBSCRIPTION, 2, ref 1000101-3, some subscription, M-1000101:ADJ:-2)", + "CoopShareTransaction(M-1000101: 2022-10-21, ADJUSTMENT, -2, ref 1000101-4, some adjustment, M-1000101:SUB:+2)", - "CoopShareTransaction(1000202, 2010-03-15, SUBSCRIPTION, 4, ref 1000202-1, initial subscription)", - "CoopShareTransaction(1000202, 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)", - "CoopShareTransaction(1000202, 2022-10-20, ADJUSTMENT, 2, ref 1000202-3, some adjustment)", + "CoopShareTransaction(M-1000202: 2010-03-15, SUBSCRIPTION, 4, ref 1000202-1, initial subscription)", + "CoopShareTransaction(M-1000202: 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)", + "CoopShareTransaction(M-1000202: 2022-10-20, SUBSCRIPTION, 2, ref 1000202-3, some subscription, M-1000202:ADJ:-2)", + "CoopShareTransaction(M-1000202: 2022-10-21, ADJUSTMENT, -2, ref 1000202-4, some adjustment, M-1000202:SUB:+2)", - "CoopShareTransaction(1000303, 2010-03-15, SUBSCRIPTION, 4, ref 1000303-1, initial subscription)", - "CoopShareTransaction(1000303, 2021-09-01, CANCELLATION, -2, ref 1000303-2, cancelling some)", - "CoopShareTransaction(1000303, 2022-10-20, ADJUSTMENT, 2, ref 1000303-3, some adjustment)"); + "CoopShareTransaction(M-1000303: 2010-03-15, SUBSCRIPTION, 4, ref 1000303-1, initial subscription)", + "CoopShareTransaction(M-1000303: 2021-09-01, CANCELLATION, -2, ref 1000303-2, cancelling some)", + "CoopShareTransaction(M-1000303: 2022-10-20, SUBSCRIPTION, 2, ref 1000303-3, some subscription, M-1000303:ADJ:-2)", + "CoopShareTransaction(M-1000303: 2022-10-21, ADJUSTMENT, -2, ref 1000303-4, some adjustment, M-1000303:SUB:+2)"); } @Test @@ -166,11 +168,12 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase null); // then - allTheseCoopSharesTransactionsAreReturned( + exactlyTheseCoopSharesTransactionsAreReturned( result, - "CoopShareTransaction(1000202, 2010-03-15, SUBSCRIPTION, 4, ref 1000202-1, initial subscription)", - "CoopShareTransaction(1000202, 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)", - "CoopShareTransaction(1000202, 2022-10-20, ADJUSTMENT, 2, ref 1000202-3, some adjustment)"); + "CoopShareTransaction(M-1000202: 2010-03-15, SUBSCRIPTION, 4, ref 1000202-1, initial subscription)", + "CoopShareTransaction(M-1000202: 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)", + "CoopShareTransaction(M-1000202: 2022-10-20, SUBSCRIPTION, 2, ref 1000202-3, some subscription, M-1000202:ADJ:-2)", + "CoopShareTransaction(M-1000202: 2022-10-21, ADJUSTMENT, -2, ref 1000202-4, some adjustment, M-1000202:SUB:+2)"); } @Test @@ -188,13 +191,13 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase // then allTheseCoopSharesTransactionsAreReturned( result, - "CoopShareTransaction(1000202, 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)"); + "CoopShareTransaction(M-1000202: 2021-09-01, CANCELLATION, -2, ref 1000202-2, cancelling some)"); } @Test public void normalUser_canViewOnlyRelatedCoopSharesTransactions() { // given: - context("superuser-alex@hostsharing.net", "hs_office_partner#10001:FirstGmbH-firstcontact.admin"); + context("superuser-alex@hostsharing.net", "hs_office_membership#M-1000101:ADMIN"); // when: final var result = coopSharesTransactionRepo.findCoopSharesTransactionByOptionalMembershipUuidAndDateRange( @@ -205,9 +208,10 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase // then: exactlyTheseCoopSharesTransactionsAreReturned( result, - "CoopShareTransaction(1000101, 2010-03-15, SUBSCRIPTION, 4, ref 1000101-1, initial subscription)", - "CoopShareTransaction(1000101, 2021-09-01, CANCELLATION, -2, ref 1000101-2, cancelling some)", - "CoopShareTransaction(1000101, 2022-10-20, ADJUSTMENT, 2, ref 1000101-3, some adjustment)"); + "CoopShareTransaction(M-1000101: 2010-03-15, SUBSCRIPTION, 4, ref 1000101-1, initial subscription)", + "CoopShareTransaction(M-1000101: 2021-09-01, CANCELLATION, -2, ref 1000101-2, cancelling some)", + "CoopShareTransaction(M-1000101: 2022-10-20, SUBSCRIPTION, 2, ref 1000101-3, some subscription, M-1000101:ADJ:-2)", + "CoopShareTransaction(M-1000101: 2022-10-21, ADJUSTMENT, -2, ref 1000101-4, some adjustment, M-1000101:SUB:+2)"); } } @@ -215,9 +219,8 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'reference' + from tx_journal_v where targettable = 'hs_office_coopsharestransaction'; """); @@ -226,8 +229,18 @@ class HsOfficeCoopSharesTransactionRepositoryIntegrationTest extends ContextBase // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating coopSharesTransaction test-data 1000101, hs_office_coopsharestransaction, INSERT]", - "[creating coopSharesTransaction test-data 1000202, hs_office_coopsharestransaction, INSERT]"); + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000101-1]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000101-2]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000101-3]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000101-4]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000202-1]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000202-2]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000202-3]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000202-4]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000303-1]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000303-2]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000303-3]", + "[creating coopSharesTransaction test-data, hs_office_coopsharestransaction, INSERT, ref 1000303-4]"); } @BeforeEach diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java index 7085fe53..68545a78 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java @@ -5,10 +5,13 @@ import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -23,8 +26,9 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.*; @@ -33,7 +37,7 @@ import static org.hamcrest.Matchers.*; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeDebitorControllerAcceptanceTest { +class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanup { private static final int LOWEST_TEMP_DEBITOR_SUFFIX = 90; private static byte nextDebitorSuffix = LOWEST_TEMP_DEBITOR_SUFFIX; @@ -51,11 +55,17 @@ class HsOfficeDebitorControllerAcceptanceTest { HsOfficePartnerRepository partnerRepo; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRealRepository contactrealRepo; @Autowired HsOfficeBankAccountRepository bankAccountRepo; + @Autowired + HsOfficePersonRepository personRepo; + + @Autowired + HsOfficeRelationRealRepository relrealRepo; + @Autowired JpaAttempt jpaAttempt; @@ -63,7 +73,6 @@ class HsOfficeDebitorControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "Debitor:F(Find)" }) class ListDebitors { @Test @@ -80,43 +89,149 @@ class HsOfficeDebitorControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" [ - { - "debitorNumber": 1000111, - "debitorNumberSuffix": 11, - "partner": { "person": { "personType": "LEGAL_PERSON" } }, - "billingContact": { "label": "first contact" }, - "vatId": null, - "vatCountryCode": null, - "vatBusiness": true, - "refundBankAccount": { "holder": "First GmbH" } - }, - { - "debitorNumber": 1000212, - "debitorNumberSuffix": 12, - "partner": { "person": { "tradeName": "Second e.K." } }, - "billingContact": { "label": "second contact" }, - "vatId": null, - "vatCountryCode": null, - "vatBusiness": true, - "refundBankAccount": { "holder": "Second e.K." } - }, - { - "debitorNumber": 1000313, - "debitorNumberSuffix": 13, - "partner": { "person": { "tradeName": "Third OHG" } }, - "billingContact": { "label": "third contact" }, - "vatId": null, - "vatCountryCode": null, - "vatBusiness": true, - "refundBankAccount": { "holder": "Third OHG" } - } - ] + { + "debitorRel": { + "anchor": { + "personType": "LEGAL_PERSON", + "tradeName": "First GmbH", + "givenName": null, + "familyName": null + }, + "holder": { + "personType": "LEGAL_PERSON", + "tradeName": "First GmbH", + "givenName": null, + "familyName": null + }, + "type": "DEBITOR", + "mark": null, + "contact": { + "caption": "first contact", + "emailAddresses": { "main": "contact-admin@firstcontact.example.com" }, + "phoneNumbers": { "phone_office": "+49 123 1234567" } + } + }, + "debitorNumber": 1000111, + "debitorNumberSuffix": 11, + "partner": { + "partnerNumber": 10001, + "partnerRel": { + "anchor": { + "personType": "LEGAL_PERSON", + "tradeName": "Hostsharing eG", + "givenName": null, + "familyName": null + }, + "holder": { + "personType": "LEGAL_PERSON", + "tradeName": "First GmbH", + "givenName": null, + "familyName": null + }, + "type": "PARTNER", + "mark": null, + "contact": { + "caption": "first contact", + "emailAddresses": { "main": "contact-admin@firstcontact.example.com" }, + "phoneNumbers": { "phone_office": "+49 123 1234567" } + } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789", + "birthName": null, + "birthPlace": null, + "birthday": null, + "dateOfDeath": null + } + }, + "billable": true, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true, + "vatReverseCharge": false, + "refundBankAccount": { + "holder": "First GmbH", + "iban": "DE02120300000000202051", + "bic": "BYLADEM1001" + }, + "defaultPrefix": "fir" + }, + { + "debitorRel": { + "anchor": {"tradeName": "Second e.K."}, + "holder": {"tradeName": "Second e.K."}, + "type": "DEBITOR", + "contact": { + "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } + } + }, + "debitorNumber": 1000212, + "debitorNumberSuffix": 12, + "partner": { + "partnerNumber": 10002, + "partnerRel": { + "anchor": {"tradeName": "Hostsharing eG"}, + "holder": {"tradeName": "Second e.K."}, + "type": "PARTNER", + "contact": { + "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } + } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789" + } + }, + "billable": true, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true, + "vatReverseCharge": false, + "refundBankAccount": {"iban": "DE02100500000054540402"}, + "defaultPrefix": "sec" + }, + { + "debitorRel": { + "anchor": {"tradeName": "Third OHG"}, + "holder": {"tradeName": "Third OHG"}, + "type": "DEBITOR", + "contact": { + "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } + } + }, + "debitorNumber": 1000313, + "debitorNumberSuffix": 13, + "partner": { + "partnerNumber": 10003, + "partnerRel": { + "anchor": {"tradeName": "Hostsharing eG"}, + "holder": {"tradeName": "Third OHG"}, + "type": "PARTNER", + "contact": { + "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } + } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789" + } + }, + "billable": true, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true, + "vatReverseCharge": false, + "refundBankAccount": {"iban": "DE02300209000106531065"}, + "defaultPrefix": "thi" + } + ] """)); // @formatter:on } @Test - void globalAdmin_withoutAssumedRoles_canFindDebitorDebitorByDebitorNumber() throws JSONException { + void globalAdmin_withoutAssumedRoles_canFindDebitorDebitorByDebitorNumber() { RestAssured // @formatter:off .given() @@ -131,8 +246,10 @@ class HsOfficeDebitorControllerAcceptanceTest { [ { "debitorNumber": 1000212, - "partner": { "person": { "tradeName": "Second e.K." } }, - "billingContact": { "label": "second contact" }, + "partner": { "partnerNumber": 10002 }, + "debitorRel": { + "contact": { "caption": "second contact" } + }, "vatId": null, "vatCountryCode": null, "vatBusiness": true @@ -144,16 +261,26 @@ class HsOfficeDebitorControllerAcceptanceTest { } @Nested - @Accepts({ "Debitor:C(Create)" }) - class CreateDebitor { + class AddDebitor { @Test void globalAdmin_withoutAssumedRole_canAddDebitorWithBankAccount() { context.define("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var givenBankAccount = bankAccountRepo.findByOptionalHolderLike("Fourth").get(0); + final var givenBillingPerson = personRepo.findPersonByOptionalNameLike("Fourth").get(0); + + final var givenDebitorRelUUid = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + return relrealRepo.save(HsOfficeRelationRealEntity.builder() + .type(DEBITOR) + .anchor(givenPartner.getPartnerRel().getHolder()) + .holder(givenBillingPerson) + .contact(givenContact) + .build()).getUuid(); + }).assertSuccessful().returnedValue(); final var location = RestAssured // @formatter:off .given() @@ -161,8 +288,7 @@ class HsOfficeDebitorControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "partnerUuid": "%s", - "billingContactUuid": "%s", + "debitorRelUuid": "%s", "debitorNumberSuffix": "%s", "billable": "true", "vatId": "VAT123456", @@ -172,7 +298,7 @@ class HsOfficeDebitorControllerAcceptanceTest { "refundBankAccountUuid": "%s", "defaultPrefix": "for" } - """.formatted( givenPartner.getUuid(), givenContact.getUuid(), ++nextDebitorSuffix, givenBankAccount.getUuid())) + """.formatted( givenDebitorRelUUid, ++nextDebitorSuffix, givenBankAccount.getUuid())) .port(port) .when() .post("http://localhost/api/hs/office/debitors") @@ -182,8 +308,8 @@ class HsOfficeDebitorControllerAcceptanceTest { .body("uuid", isUuidValid()) .body("vatId", is("VAT123456")) .body("defaultPrefix", is("for")) - .body("billingContact.label", is(givenContact.getLabel())) - .body("partner.person.tradeName", is(givenPartner.getPerson().getTradeName())) + .body("debitorRel.contact.caption", is(givenContact.getCaption())) + .body("debitorRel.holder.tradeName", is(givenBillingPerson.getTradeName())) .body("refundBankAccount.holder", is(givenBankAccount.getHolder())) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on @@ -199,22 +325,30 @@ class HsOfficeDebitorControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var location = RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerUuid": "%s", - "billingContactUuid": "%s", - "debitorNumberSuffix": "%s", - "defaultPrefix": "for", - "billable": "true", - "vatReverseCharge": "false" - } - """.formatted( givenPartner.getUuid(), givenContact.getUuid(), ++nextDebitorSuffix)) + { + "debitorRel": { + "type": "DEBITOR", + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "debitorNumberSuffix": "%s", + "defaultPrefix": "for", + "billable": "true", + "vatReverseCharge": "false" + } + """.formatted( + givenPartner.getPartnerRel().getHolder().getUuid(), + givenPartner.getPartnerRel().getHolder().getUuid(), + givenContact.getUuid(), + ++nextDebitorSuffix)) .port(port) .when() .post("http://localhost/api/hs/office/debitors") @@ -222,8 +356,8 @@ class HsOfficeDebitorControllerAcceptanceTest { .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("billingContact.label", is(givenContact.getLabel())) - .body("partner.person.tradeName", is(givenPartner.getPerson().getTradeName())) + .body("debitorRel.contact.caption", is(givenContact.getCaption())) + .body("partner.partnerRel.holder.tradeName", is(givenPartner.getPartnerRel().getHolder().getTradeName())) .body("vatId", equalTo(null)) .body("vatCountryCode", equalTo(null)) .body("vatBusiness", equalTo(false)) @@ -243,71 +377,68 @@ class HsOfficeDebitorControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); - final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenContactUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); final var location = RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerUuid": "%s", - "billingContactUuid": "%s", - "debitorNumberSuffix": "%s", - "billable": "true", - "vatId": "VAT123456", - "vatCountryCode": "DE", - "vatBusiness": true, - "vatReverseCharge": "false", - "defaultPrefix": "thi" - } - """ - .formatted( givenPartner.getUuid(), givenContactUuid, ++nextDebitorSuffix)) + { + "debitorRel": { + "type": "DEBITOR", + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "debitorNumberSuffix": "%s", + "defaultPrefix": "for", + "billable": "true", + "vatReverseCharge": "false" + } + """.formatted( + givenPartner.getPartnerRel().getAnchor().getUuid(), + givenPartner.getPartnerRel().getAnchor().getUuid(), + givenContactUuid, ++nextDebitorSuffix)) .port(port) .when() .post("http://localhost/api/hs/office/debitors") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find Contact with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [400] Unable to find RealContact by debitorRel.contactUuid: 00000000-0000-0000-0000-000000000000")); // @formatter:on } @Test - void globalAdmin_canNotAddDebitor_ifPartnerDoesNotExist() { + void globalAdmin_canNotAddDebitor_ifDebitorRelDoesNotExist() { context.define("superuser-alex@hostsharing.net"); - final var givenPartnerUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenDebitorRelUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerUuid": "%s", - "billingContactUuid": "%s", - "debitorNumberSuffix": "%s", - "billable": "true", - "vatId": "VAT123456", - "vatCountryCode": "DE", - "vatBusiness": true, - "vatReverseCharge": "false", - "defaultPrefix": "for" - } - """.formatted( givenPartnerUuid, givenContact.getUuid(), ++nextDebitorSuffix)) + { + "debitorRelUuid": "%s", + "debitorNumberSuffix": "%s", + "defaultPrefix": "for", + "billable": "true", + "vatReverseCharge": "false" + } + """.formatted(givenDebitorRelUuid, ++nextDebitorSuffix)) .port(port) .when() .post("http://localhost/api/hs/office/debitors") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find Partner with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [400] Unable to find RealRelation by uuid: 00000000-0000-0000-0000-000000000000")); // @formatter:on } } @Nested - @Accepts({ "Debitor:R(Read)" }) class GetDebitor { @Test @@ -321,19 +452,57 @@ class HsOfficeDebitorControllerAcceptanceTest { .port(port) .when() .get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid) - .then().log().body().assertThat() + .then().log().all().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" { - "partner": { person: { "tradeName": "First GmbH" } }, - "billingContact": { "label": "first contact" } - } + "debitorRel": { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "First GmbH"}, + "holder": { "personType": "LEGAL_PERSON", "tradeName": "First GmbH"}, + "type": "DEBITOR", + "contact": { + "caption": "first contact", + "postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt", + "emailAddresses": { "main": "contact-admin@firstcontact.example.com" }, + "phoneNumbers": { "phone_office": "+49 123 1234567" } + } + }, + "debitorNumber": 1000111, + "debitorNumberSuffix": 11, + "partner": { + "partnerNumber": 10001, + "partnerRel": { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG"}, + "holder": { "personType": "LEGAL_PERSON", "tradeName": "First GmbH"}, + "type": "PARTNER", + "mark": null, + "contact": { + "caption": "first contact", + "postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt", + "emailAddresses": { "main": "contact-admin@firstcontact.example.com" }, + "phoneNumbers": { "phone_office": "+49 123 1234567" } + } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789" + } + }, + "billable": true, + "vatBusiness": true, + "vatReverseCharge": false, + "refundBankAccount": { + "holder": "First GmbH", + "iban": "DE02120300000000202051", + "bic": "BYLADEM1001" + }, + "defaultPrefix": "fir" + } """)); // @formatter:on } @Test - @Accepts({ "Debitor:X(Access Control)" }) void normalUser_canNotGetUnrelatedDebitor() { context.define("superuser-alex@hostsharing.net"); final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("First").get(0).getUuid(); @@ -349,8 +518,7 @@ class HsOfficeDebitorControllerAcceptanceTest { } @Test - @Accepts({ "Debitor:X(Access Control)" }) - void contactAdminUser_canGetRelatedDebitor() { + void contactAdminUser_canGetRelatedDebitorExceptRefundBankAccount() { context.define("superuser-alex@hostsharing.net"); final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("first contact").get(0).getUuid(); @@ -365,24 +533,24 @@ class HsOfficeDebitorControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "partner": { person: { "tradeName": "First GmbH" } }, - "billingContact": { "label": "first contact" }, - "refundBankAccount": { "holder": "First GmbH" } + "debitorNumber": 1000111, + "partner": { "partnerNumber": 10001 }, + "debitorRel": { "contact": { "caption": "first contact" } }, + "refundBankAccount": null } """)); // @formatter:on } } @Nested - @Accepts({ "Debitor:U(Update)" }) class PatchDebitor { @Test - void globalAdmin_withoutAssumedRole_canPatchAllPropertiesOfArbitraryDebitor() { + void globalAdmin_withoutAssumedRole_canPatchArbitraryDebitor() { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor(); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var location = RestAssured // @formatter:off .given() @@ -400,81 +568,95 @@ class HsOfficeDebitorControllerAcceptanceTest { .port(port) .when() .patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) - .then().assertThat() + .then().log().all().assertThat() .statusCode(200) .contentType(ContentType.JSON) - .body("uuid", isUuidValid()) - .body("vatId", is("VAT222222")) - .body("vatCountryCode", is("AA")) - .body("vatBusiness", is(true)) - .body("defaultPrefix", is("for")) - .body("billingContact.label", is(givenContact.getLabel())) - .body("partner.person.tradeName", is(givenDebitor.getPartner().getPerson().getTradeName())); + .body("", lenientlyEquals(""" + { + "debitorRel": { + "anchor": { "tradeName": "Fourth eG" }, + "holder": { "tradeName": "Fourth eG" }, + "type": "DEBITOR", + "mark": null, + "contact": { "caption": "fourth contact" } + }, + "debitorNumber": 10004${debitorNumberSuffix}, + "debitorNumberSuffix": ${debitorNumberSuffix}, + "partner": { + "partnerNumber": 10004, + "partnerRel": { + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "Fourth eG" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "fourth contact" } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789", + "birthName": null, + "birthPlace": null, + "birthday": null, + "dateOfDeath": null + } + }, + "billable": true, + "vatId": "VAT222222", + "vatCountryCode": "AA", + "vatBusiness": true, + "vatReverseCharge": false, + "defaultPrefix": "for" + } + """ + .replace("${debitorNumberSuffix}", givenDebitor.getDebitorNumberSuffix())) + ); // @formatter:on // finally, the debitor is actually updated - context.define("superuser-alex@hostsharing.net"); - assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get() - .matches(partner -> { - assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner() - .getPerson() - .getTradeName()); - assertThat(partner.getBillingContact().getLabel()).isEqualTo("forth contact"); - assertThat(partner.getVatId()).isEqualTo("VAT222222"); - assertThat(partner.getVatCountryCode()).isEqualTo("AA"); - assertThat(partner.isVatBusiness()).isEqualTo(true); - return true; - }); + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get() + .matches(debitor -> { + assertThat(debitor.getDebitorRel().getHolder().getTradeName()) + .isEqualTo(givenDebitor.getDebitorRel().getHolder().getTradeName()); + assertThat(debitor.getDebitorRel().getContact().getCaption()).isEqualTo("fourth contact"); + assertThat(debitor.getVatId()).isEqualTo("VAT222222"); + assertThat(debitor.getVatCountryCode()).isEqualTo("AA"); + assertThat(debitor.isVatBusiness()).isEqualTo(true); + return true; + }); + }).assertSuccessful(); } @Test - void globalAdmin_withoutAssumedRole_canPatchPartialPropertiesOfArbitraryDebitor() { + void theContactOwner_canNotPatchARelatedDebitor() { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor(); - final var newBillingContact = contactRepo.findContactByOptionalLabelLike("sixth").get(0); - final var location = RestAssured // @formatter:off - .given() + // @formatter:on + RestAssured // @formatter:off + .given() .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_office_contact#fourthcontact:ADMIN") .contentType(ContentType.JSON) .body(""" - { - "billingContactUuid": "%s", - "vatId": "VAT999999" - } - """.formatted(newBillingContact.getUuid())) + { + "vatId": "VAT999999" + } + """) .port(port) - .when() + .when() .patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) - .then().assertThat() - .statusCode(200) - .contentType(ContentType.JSON) - .body("uuid", isUuidValid()) - .body("billingContact.label", is("sixth contact")) - .body("vatId", is("VAT999999")) - .body("vatCountryCode", is(givenDebitor.getVatCountryCode())) - .body("vatBusiness", is(givenDebitor.isVatBusiness())); - // @formatter:on + .then().log().all().assertThat() + .statusCode(403) + .body("message", containsString("ERROR: [403] Subject")) + .body("message", containsString("is not allowed to update hs_office_debitor uuid ")); - // finally, the debitor is actually updated - assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get() - .matches(partner -> { - assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner() - .getPerson() - .getTradeName()); - assertThat(partner.getBillingContact().getLabel()).isEqualTo("sixth contact"); - assertThat(partner.getVatId()).isEqualTo("VAT999999"); - assertThat(partner.getVatCountryCode()).isEqualTo(givenDebitor.getVatCountryCode()); - assertThat(partner.isVatBusiness()).isEqualTo(givenDebitor.isVatBusiness()); - return true; - }); } - } @Nested - @Accepts({ "Debitor:D(Delete)" }) class DeleteDebitor { @Test @@ -496,15 +678,14 @@ class HsOfficeDebitorControllerAcceptanceTest { } @Test - @Accepts({ "Debitor:X(Access Control)" }) void contactAdminUser_canNotDeleteRelatedDebitor() { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor(); - assertThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact"); + assertThat(givenDebitor.getDebitorRel().getContact().getCaption()).isEqualTo("fourth contact"); RestAssured // @formatter:off .given() - .header("current-user", "contact-admin@forthcontact.example.com") + .header("current-user", "contact-admin@fourthcontact.example.com") .port(port) .when() .delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) @@ -516,11 +697,10 @@ class HsOfficeDebitorControllerAcceptanceTest { } @Test - @Accepts({ "Debitor:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedDebitor() { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor(); - assertThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact"); + assertThat(givenDebitor.getDebitorRel().getContact().getCaption()).isEqualTo("fourth contact"); RestAssured // @formatter:off .given() @@ -539,18 +719,24 @@ class HsOfficeDebitorControllerAcceptanceTest { private HsOfficeDebitorEntity givenSomeTemporaryDebitor() { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0).load(); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth contact").get(0); final var newDebitor = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix(++nextDebitorSuffix) + .debitorNumberSuffix(nextDebitorSuffix()) .billable(true) - .partner(givenPartner) - .billingContact(givenContact) + .debitorRel( + HsOfficeRelationRealEntity.builder() + .type(DEBITOR) + .anchor(givenPartner.getPartnerRel().getHolder()) + .holder(givenPartner.getPartnerRel().getHolder()) + .contact(givenContact) + .build() + ) .defaultPrefix("abc") .vatReverseCharge(false) .build(); - return debitorRepo.save(newDebitor); + return debitorRepo.save(newDebitor).load(); }).assertSuccessful().returnedValue(); } @@ -560,9 +746,13 @@ class HsOfficeDebitorControllerAcceptanceTest { jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); final var count = em.createQuery( - "DELETE FROM HsOfficeDebitorEntity d WHERE d.debitorNumberSuffix >= " + LOWEST_TEMP_DEBITOR_SUFFIX) + "DELETE FROM HsBookingDebitorEntity d WHERE d.debitorNumberSuffix >= " + LOWEST_TEMP_DEBITOR_SUFFIX) .executeUpdate(); System.out.printf("deleted %d entities%n", count); }); } + + private String nextDebitorSuffix() { + return String.format("%02d", nextDebitorSuffix++); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java index 01ea5777..52ddb318 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java @@ -1,10 +1,9 @@ package net.hostsharing.hsadminng.hs.office.debitor; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource; -import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,15 +21,11 @@ import static org.mockito.Mockito.lenient; @TestInstance(PER_CLASS) @ExtendWith(MockitoExtension.class) -class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< - HsOfficeDebitorPatchResource, - HsOfficeDebitorEntity - > { +class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase { private static final UUID INITIAL_DEBITOR_UUID = UUID.randomUUID(); - private static final UUID INITIAL_PARTNER_UUID = UUID.randomUUID(); - private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID(); - private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); + private static final UUID INITIAL_DEBITOR_REL_UUID = UUID.randomUUID(); + private static final UUID PATCHED_DEBITOR_REL_UUID = UUID.randomUUID(); private static final String PATCHED_DEFAULT_PREFIX = "xyz"; private static final String PATCHED_VAT_COUNTRY_CODE = "ZZ"; @@ -46,24 +41,21 @@ class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< private static final UUID INITIAL_REFUND_BANK_ACCOUNT_UUID = UUID.randomUUID(); private static final UUID PATCHED_REFUND_BANK_ACCOUNT_UUID = UUID.randomUUID(); - private final HsOfficePartnerEntity givenInitialPartner = HsOfficePartnerEntity.builder() - .uuid(INITIAL_PARTNER_UUID) - .build(); - - private final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() - .uuid(INITIAL_CONTACT_UUID) + private final HsOfficeRelationRealEntity givenInitialDebitorRel = HsOfficeRelationRealEntity.builder() + .uuid(INITIAL_DEBITOR_REL_UUID) .build(); private final HsOfficeBankAccountEntity givenInitialBankAccount = HsOfficeBankAccountEntity.builder() .uuid(INITIAL_REFUND_BANK_ACCOUNT_UUID) .build(); + @Mock private EntityManager em; @BeforeEach void initMocks() { - lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> - HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeRelationRealEntity.class), any())).thenAnswer(invocation -> + HsOfficeRelationRealEntity.builder().uuid(invocation.getArgument(1)).build()); lenient().when(em.getReference(eq(HsOfficeBankAccountEntity.class), any())).thenAnswer(invocation -> HsOfficeBankAccountEntity.builder().uuid(invocation.getArgument(1)).build()); } @@ -72,8 +64,7 @@ class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< protected HsOfficeDebitorEntity newInitialEntity() { final var entity = new HsOfficeDebitorEntity(); entity.setUuid(INITIAL_DEBITOR_UUID); - entity.setPartner(givenInitialPartner); - entity.setBillingContact(givenInitialContact); + entity.setDebitorRel(givenInitialDebitorRel); entity.setBillable(INITIAL_BILLABLE); entity.setVatId("initial VAT-ID"); entity.setVatCountryCode("AA"); @@ -98,11 +89,11 @@ class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< protected Stream propertyTestDescriptors() { return Stream.of( new JsonNullableProperty<>( - "billingContact", - HsOfficeDebitorPatchResource::setBillingContactUuid, - PATCHED_CONTACT_UUID, - HsOfficeDebitorEntity::setBillingContact, - newBillingContact(PATCHED_CONTACT_UUID)) + "debitorRel", + HsOfficeDebitorPatchResource::setDebitorRelUuid, + PATCHED_DEBITOR_REL_UUID, + HsOfficeDebitorEntity::setDebitorRel, + newDebitorRel(PATCHED_DEBITOR_REL_UUID)) .notNullable(), new SimpleProperty<>( "billable", @@ -129,7 +120,7 @@ class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< new SimpleProperty<>( "vatReverseCharge", HsOfficeDebitorPatchResource::setVatReverseCharge, - PATCHED_BILLABLE, + PATCHED_VAT_REVERSE_CHARGE, HsOfficeDebitorEntity::setVatReverseCharge) .notNullable(), new JsonNullableProperty<>( @@ -148,15 +139,15 @@ class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< ); } - private HsOfficeContactEntity newBillingContact(final UUID uuid) { - final var newContact = new HsOfficeContactEntity(); - newContact.setUuid(uuid); - return newContact; + private HsOfficeRelationRealEntity newDebitorRel(final UUID uuid) { + return HsOfficeRelationRealEntity.builder() + .uuid(uuid) + .build(); } private HsOfficeBankAccountEntity newBankAccount(final UUID uuid) { - final var newBankAccount = new HsOfficeBankAccountEntity(); - newBankAccount.setUuid(uuid); - return newBankAccount; + return HsOfficeBankAccountEntity.builder() + .uuid(uuid) + .build(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityUnitTest.java index 96f1ba13..5dc61235 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityUnitTest.java @@ -1,61 +1,52 @@ package net.hostsharing.hsadminng.hs.office.debitor; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerDetailsEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class HsOfficeDebitorEntityUnitTest { + private HsOfficeRelationRealEntity givenDebitorRel = HsOfficeRelationRealEntity.builder() + .anchor(HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.LEGAL_PERSON) + .tradeName("some partner trade name") + .build()) + .holder(HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.LEGAL_PERSON) + .tradeName("some billing trade name") + .build()) + .contact(HsOfficeContactRealEntity.builder().caption("some caption").build()) + .build(); + @Test void toStringContainsPartnerAndContact() { final var given = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)67) - .partner(HsOfficePartnerEntity.builder() - .person(HsOfficePersonEntity.builder() - .personType(HsOfficePersonType.LEGAL_PERSON) - .tradeName("some trade name") - .build()) - .details(HsOfficePartnerDetailsEntity.builder().birthName("some birth name").build()) - .partnerNumber(12345) - .build()) - .billingContact(HsOfficeContactEntity.builder().label("some label").build()) + .debitorNumberSuffix("67") + .debitorRel(givenDebitorRel) .defaultPrefix("som") - .build(); - - final var result = given.toString(); - - assertThat(result).isEqualTo("debitor(D-1234567: LP some trade name: som)"); - } - - @Test - void toStringWithoutPersonContainsDebitorNumber() { - final var given = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)67) .partner(HsOfficePartnerEntity.builder() - .person(null) - .details(HsOfficePartnerDetailsEntity.builder().birthName("some birth name").build()) .partnerNumber(12345) .build()) - .billingContact(HsOfficeContactEntity.builder().label("some label").build()) .build(); final var result = given.toString(); - assertThat(result).isEqualTo("debitor(D-1234567: )"); + assertThat(result).isEqualTo("debitor(D-1234567: rel(anchor='LP some partner trade name', holder='LP some billing trade name'), som)"); } @Test void toShortStringContainsDebitorNumber() { final var given = HsOfficeDebitorEntity.builder() + .debitorRel(givenDebitorRel) + .debitorNumberSuffix("67") .partner(HsOfficePartnerEntity.builder() .partnerNumber(12345) .build()) - .debitorNumberSuffix((byte)67) .build(); final var result = given.toShortString(); @@ -66,10 +57,11 @@ class HsOfficeDebitorEntityUnitTest { @Test void getDebitorNumberWithPartnerNumberAndDebitorNumberSuffix() { final var given = HsOfficeDebitorEntity.builder() + .debitorRel(givenDebitorRel) + .debitorNumberSuffix("67") .partner(HsOfficePartnerEntity.builder() .partnerNumber(12345) .build()) - .debitorNumberSuffix((byte)67) .build(); final var result = given.getDebitorNumber(); @@ -80,8 +72,9 @@ class HsOfficeDebitorEntityUnitTest { @Test void getDebitorNumberWithoutPartnerReturnsNull() { final var given = HsOfficeDebitorEntity.builder() + .debitorRel(givenDebitorRel) + .debitorNumberSuffix("67") .partner(null) - .debitorNumberSuffix((byte)67) .build(); final var result = given.getDebitorNumber(); @@ -92,10 +85,9 @@ class HsOfficeDebitorEntityUnitTest { @Test void getDebitorNumberWithoutPartnerNumberReturnsNull() { final var given = HsOfficeDebitorEntity.builder() - .partner(HsOfficePartnerEntity.builder() - .partnerNumber(null) - .build()) - .debitorNumberSuffix((byte)67) + .debitorRel(givenDebitorRel) + .debitorNumberSuffix("67") + .partner(HsOfficePartnerEntity.builder().build()) .build(); final var result = given.getDebitorNumber(); @@ -106,10 +98,11 @@ class HsOfficeDebitorEntityUnitTest { @Test void getDebitorNumberWithoutDebitorNumberSuffixReturnsNull() { final var given = HsOfficeDebitorEntity.builder() + .debitorRel(givenDebitorRel) + .debitorNumberSuffix(null) .partner(HsOfficePartnerEntity.builder() .partnerNumber(12345) .build()) - .debitorNumberSuffix(null) .build(); final var result = given.getDebitorNumber(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java index 1fff4dce..1d16254d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java @@ -1,14 +1,21 @@ package net.hostsharing.hsadminng.hs.office.debitor; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,14 +33,15 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.List; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.test.EntityList.one; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@Import( { Context.class, JpaAttempt.class }) -class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { +@Import( { Context.class, JpaAttempt.class, RbacGrantsDiagramService.class }) +class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeDebitorRepository debitorRepo; @@ -42,7 +50,10 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { HsOfficePartnerRepository partnerRepo; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRealRepository contactrealRepo; + + @Autowired + HsOfficePersonRepository personRepo; @Autowired HsOfficeBankAccountRepository bankAccountRepo; @@ -59,9 +70,11 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { @Autowired JpaAttempt jpaAttempt; + @Autowired + RbacGrantsDiagramService mermaidService; + @MockBean HttpServletRequest request; - @Nested class CreateDebitor { @@ -70,19 +83,26 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // given context("superuser-alex@hostsharing.net"); final var count = debitorRepo.count(); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First GmbH").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("first contact").get(0); + final var givenPartner = partnerRepo.findPartnerByPartnerNumber(10001); + final var givenPartnerPerson = one(personRepo.findPersonByOptionalNameLike("First GmbH")); + final var givenContact = one(contactrealRepo.findContactByOptionalCaptionLike("first contact")); // when final var result = attempt(em, () -> { final var newDebitor = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)21) .partner(givenPartner) - .billingContact(givenContact) + .debitorNumberSuffix("21") + .debitorRel(HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.DEBITOR) + .anchor(givenPartnerPerson) + .holder(givenPartnerPerson) + .contact(givenContact) + .build()) .defaultPrefix("abc") .billable(false) .build(); - return debitorRepo.save(newDebitor); + final HsOfficeDebitorEntity entity = debitorRepo.save(newDebitor); + return toCleanup(entity.load()); }); // then @@ -92,108 +112,116 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { assertThat(debitorRepo.count()).isEqualTo(count + 1); } + @Transactional @ParameterizedTest @ValueSource(strings = {"", "a", "ab", "a12", "123", "12a"}) - @Transactional public void canNotCreateNewDebitorWithInvalidDefaultPrefix(final String givenPrefix) { // given context("superuser-alex@hostsharing.net"); - final var count = debitorRepo.count(); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First GmbH").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("first contact").get(0); + final var givenPartnerPerson = one(personRepo.findPersonByOptionalNameLike("First GmbH")); + final var givenContact = one(contactrealRepo.findContactByOptionalCaptionLike("first contact")); // when final var result = attempt(em, () -> { final var newDebitor = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)21) - .partner(givenPartner) - .billingContact(givenContact) + .debitorNumberSuffix("21") + .debitorRel(HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.DEBITOR) + .anchor(givenPartnerPerson) + .holder(givenPartnerPerson) + .contact(givenContact) + .build()) .billable(true) .vatReverseCharge(false) .vatBusiness(false) .defaultPrefix(givenPrefix) .build(); - return debitorRepo.save(newDebitor); + return toCleanup(debitorRepo.save(newDebitor)); }); // then - result.assertExceptionWithRootCauseMessage(org.hibernate.exception.ConstraintViolationException.class); + result.assertExceptionWithRootCauseMessage(org.hibernate.exception.ConstraintViolationException.class, + "ERROR: new row for relation \"hs_office_debitor\" violates check constraint \"check_default_prefix\""); } @Test public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() // some search+replace to make the output fit into the screen width - .map(s -> s.replace("superuser-alex@hostsharing.net", "superuser-alex")) - .map(s -> s.replace("22Fourthe.G.-forthcontact", "FeG")) - .map(s -> s.replace("Fourthe.G.-forthcontact", "FeG")) - .map(s -> s.replace("forthcontact", "4th")) .map(s -> s.replace("hs_office_", "")) .toList(); // when attempt(em, () -> { - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var givenPartnerPerson = one(personRepo.findPersonByOptionalNameLike("First GmbH")); + final var givenDebitorPerson = one(personRepo.findPersonByOptionalNameLike("Fourth eG")); + final var givenContact = one(contactrealRepo.findContactByOptionalCaptionLike("fourth contact")); final var newDebitor = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)22) - .partner(givenPartner) - .billingContact(givenContact) + .debitorNumberSuffix("22") + .debitorRel(HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.DEBITOR) + .anchor(givenPartnerPerson) + .holder(givenDebitorPerson) + .contact(givenContact) + .build()) .defaultPrefix("abc") .billable(false) .build(); - return debitorRepo.save(newDebitor); + return toCleanup(debitorRepo.save(newDebitor)); }).assertSuccessful(); // then - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_office_debitor#1000422:Fourthe.G.-forthcontact.owner", - "hs_office_debitor#1000422:Fourthe.G.-forthcontact.admin", - "hs_office_debitor#1000422:Fourthe.G.-forthcontact.agent", - "hs_office_debitor#1000422:Fourthe.G.-forthcontact.tenant", - "hs_office_debitor#1000422:Fourthe.G.-forthcontact.guest")); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("superuser-alex@hostsharing.net", "superuser-alex")) - .map(s -> s.replace("22Fourthe.G.-forthcontact", "FeG")) - .map(s -> s.replace("Fourthe.G.-forthcontact", "FeG")) - .map(s -> s.replace("forthcontact", "4th")) - .map(s -> s.replace("hs_office_", "")) - .containsExactlyInAnyOrder(Array.fromFormatted( - initialGrantNames, - // owner - "{ grant perm * on debitor#1000422:FeG to role debitor#1000422:FeG.owner by system and assume }", - "{ grant role debitor#1000422:FeG.owner to role global#global.admin by system and assume }", - "{ grant role debitor#1000422:FeG.owner to user superuser-alex by global#global.admin and assume }", + "hs_office_relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER", + "hs_office_relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN", + "hs_office_relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT", + "hs_office_relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) + .map(s -> s.replace("hs_office_", "")) + .containsExactlyInAnyOrder(Array.fromFormatted( + initialGrantNames, + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:INSERT>sepamandate to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:INSERT>hs_booking_project to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", - // admin - "{ grant perm edit on debitor#1000422:FeG to role debitor#1000422:FeG.admin by system and assume }", - "{ grant role debitor#1000422:FeG.admin to role debitor#1000422:FeG.owner by system and assume }", + // owner + "{ grant perm:debitor#D-1000122:DELETE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }", + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:DELETE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER to role:person#FirstGmbH:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER to user:superuser-alex@hostsharing.net by relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER and assume }", - // agent - "{ grant role debitor#1000422:FeG.agent to role debitor#1000422:FeG.admin by system and assume }", - "{ grant role debitor#1000422:FeG.agent to role contact#4th.admin by system and assume }", - "{ grant role debitor#1000422:FeG.agent to role partner#10004:FeG.admin by system and assume }", + // admin + "{ grant perm:debitor#D-1000122:UPDATE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:UPDATE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN to role:relation#HostsharingeG-with-PARTNER-FirstGmbH:ADMIN by system and assume }", - // tenant - "{ grant role contact#4th.guest to role debitor#1000422:FeG.tenant by system and assume }", - "{ grant role debitor#1000422:FeG.tenant to role debitor#1000422:FeG.agent by system and assume }", - "{ grant role debitor#1000422:FeG.tenant to role partner#10004:FeG.agent by system and assume }", - "{ grant role partner#10004:FeG.tenant to role debitor#1000422:FeG.tenant by system and assume }", + // agent + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT to role:person#FourtheG:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT to role:relation#HostsharingeG-with-PARTNER-FirstGmbH:AGENT by system and assume }", - // guest - "{ grant perm view on debitor#1000422:FeG to role debitor#1000422:FeG.guest by system and assume }", - "{ grant role debitor#1000422:FeG.guest to role debitor#1000422:FeG.tenant by system and assume }", + // tenant + "{ grant perm:debitor#D-1000122:SELECT to role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT by system and assume }", + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:SELECT to role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-FirstGmbH:TENANT to role:relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT by system and assume }", + "{ grant role:contact#fourthcontact:REFERRER to role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT by system and assume }", + "{ grant role:person#FirstGmbH:REFERRER to role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT by system and assume }", + "{ grant role:person#FourtheG:REFERRER to role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT to role:contact#fourthcontact:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FourtheG:TENANT to role:relation#FirstGmbH-with-DEBITOR-FourtheG:AGENT by system and assume }", - null)); + null)); } private void assertThatDebitorIsPersisted(final HsOfficeDebitorEntity saved) { + final var savedRefreshed = refresh(saved); final var found = debitorRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(savedRefreshed); } } @@ -211,28 +239,29 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // then allTheseDebitorsAreReturned( result, - "debitor(D-1000111: LP First GmbH: fir)", - "debitor(D-1000212: LP Second e.K.: sec)", - "debitor(D-1000313: IF Third OHG: thi)"); + "debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)", + "debitor(D-1000212: rel(anchor='LP Second e.K.', type='DEBITOR', holder='LP Second e.K.'), sec)", + "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); } @ParameterizedTest + @Disabled // TODO: reactivate once partner.person + partner.contact are removed @ValueSource(strings = { - "hs_office_partner#10001:FirstGmbH-firstcontact.admin", - "hs_office_person#FirstGmbH.admin", - "hs_office_contact#firstcontact.admin", + "hs_office_partner#10001:FirstGmbH-firstcontact:ADMIN", + "hs_office_person#FirstGmbH:ADMIN", + "hs_office_contact#firstcontact:ADMIN", }) public void relatedPersonAdmin_canViewRelatedDebitors(final String assumedRole) { // given: context("superuser-alex@hostsharing.net", assumedRole); // when: - final var result = debitorRepo.findDebitorByOptionalNameLike(null); + final var result = debitorRepo.findDebitorByOptionalNameLike(""); // then: exactlyTheseDebitorsAreReturned(result, - "debitor(D-1000111: LP First GmbH: fir)", - "debitor(D-1000120: LP First GmbH: fif)"); + "debitor(D-1000111: P-10001, fir)", + "debitor(D-1000120: P-10001, fif)"); } @Test @@ -260,7 +289,8 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { final var result = debitorRepo.findDebitorByDebitorNumber(1000313); // then - exactlyTheseDebitorsAreReturned(result, "debitor(D-1000313: IF Third OHG: thi)"); + exactlyTheseDebitorsAreReturned(result, + "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); } } @@ -276,7 +306,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { final var result = debitorRepo.findDebitorByOptionalNameLike("third contact"); // then - exactlyTheseDebitorsAreReturned(result, "debitor(D-1000313: IF Third OHG: thi)"); + exactlyTheseDebitorsAreReturned(result, "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); } } @@ -288,13 +318,14 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // given context("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact", "Fourth", "fif"); + assertThatDebitorIsVisibleForUserWithRole( givenDebitor, - "hs_office_partner#10004:Fourthe.G.-forthcontact.admin"); - assertThatDebitorActuallyInDatabase(givenDebitor); - final var givenNewPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); - final var givenNewContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0); - final var givenNewBankAccount = bankAccountRepo.findByOptionalHolderLike("first").get(0); + "hs_office_relation#FourtheG-with-DEBITOR-FourtheG:ADMIN", true); + final var givenNewPartnerPerson = one(personRepo.findPersonByOptionalNameLike("First")); + final var givenNewBillingPerson = one(personRepo.findPersonByOptionalNameLike("Firby")); + final var givenNewContact = one(contactrealRepo.findContactByOptionalCaptionLike("sixth contact")); + final var givenNewBankAccount = one(bankAccountRepo.findByOptionalHolderLike("first")); final String givenNewVatId = "NEW-VAT-ID"; final String givenNewVatCountryCode = "NC"; final boolean givenNewVatBusiness = !givenDebitor.isVatBusiness(); @@ -302,44 +333,47 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - givenDebitor.setPartner(givenNewPartner); - givenDebitor.setBillingContact(givenNewContact); + givenDebitor.setDebitorRel(HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.DEBITOR) + .anchor(givenNewPartnerPerson) + .holder(givenNewBillingPerson) + .contact(givenNewContact) + .build()); givenDebitor.setRefundBankAccount(givenNewBankAccount); givenDebitor.setVatId(givenNewVatId); givenDebitor.setVatCountryCode(givenNewVatCountryCode); givenDebitor.setVatBusiness(givenNewVatBusiness); - return debitorRepo.save(givenDebitor); + final HsOfficeDebitorEntity entity = debitorRepo.save(givenDebitor); + return toCleanup(entity.load()); }); // then result.assertSuccessful(); - assertThatDebitorIsVisibleForUserWithRole( - result.returnedValue(), - "global#global.admin"); + assertThatDebitorIsVisibleForUserWithRole(result.returnedValue(), "global#global:ADMIN", true); // ... partner role was reassigned: assertThatDebitorIsNotVisibleForUserWithRole( result.returnedValue(), - "hs_office_partner#10004:Fourthe.G.-forthcontact.agent"); + "hs_office_relation#FourtheG-with-DEBITOR-FourtheG:ADMIN"); assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "hs_office_partner#10001:FirstGmbH-firstcontact.agent"); + "hs_office_relation#FirstGmbH-with-DEBITOR-FirbySusan:AGENT", true); // ... contact role was reassigned: assertThatDebitorIsNotVisibleForUserWithRole( result.returnedValue(), - "hs_office_contact#fifthcontact.admin"); + "hs_office_contact#fifthcontact:ADMIN"); assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "hs_office_contact#sixthcontact.admin"); + "hs_office_contact#sixthcontact:ADMIN", false); // ... bank-account role was reassigned: assertThatDebitorIsNotVisibleForUserWithRole( result.returnedValue(), - "hs_office_bankaccount#Fourthe.G..admin"); + "hs_office_bankaccount#DE02200505501015871393:ADMIN"); assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "hs_office_bankaccount#FirstGmbH.admin"); + "hs_office_bankaccount#DE02120300000000202051:ADMIN", true); } @Test @@ -349,27 +383,27 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact", null, "fig"); assertThatDebitorIsVisibleForUserWithRole( givenDebitor, - "hs_office_partner#10004:Fourthe.G.-forthcontact.admin"); - assertThatDebitorActuallyInDatabase(givenDebitor); - final var givenNewBankAccount = bankAccountRepo.findByOptionalHolderLike("first").get(0); + "hs_office_relation#FourtheG-with-DEBITOR-FourtheG:ADMIN", true); + assertThatDebitorActuallyInDatabase(givenDebitor, true); + final var givenNewBankAccount = one(bankAccountRepo.findByOptionalHolderLike("first")); // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); givenDebitor.setRefundBankAccount(givenNewBankAccount); - return debitorRepo.save(givenDebitor); + return toCleanup(debitorRepo.save(givenDebitor).load()); }); // then result.assertSuccessful(); assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "global#global.admin"); + "global#global:ADMIN", true); // ... bank-account role was assigned: assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "hs_office_bankaccount#FirstGmbH.admin"); + "hs_office_bankaccount#DE02120300000000202051:ADMIN", true); } @Test @@ -379,48 +413,48 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact", "Fourth", "fih"); assertThatDebitorIsVisibleForUserWithRole( givenDebitor, - "hs_office_partner#10004:Fourthe.G.-forthcontact.admin"); - assertThatDebitorActuallyInDatabase(givenDebitor); + "hs_office_relation#HostsharingeG-with-PARTNER-FourtheG:AGENT", true); + assertThatDebitorActuallyInDatabase(givenDebitor, true); // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); givenDebitor.setRefundBankAccount(null); - return debitorRepo.save(givenDebitor); + return toCleanup(debitorRepo.save(givenDebitor).load()); }); // then result.assertSuccessful(); assertThatDebitorIsVisibleForUserWithRole( result.returnedValue(), - "global#global.admin"); + "global#global:ADMIN", true); // ... bank-account role was removed from previous bank-account admin: assertThatDebitorIsNotVisibleForUserWithRole( result.returnedValue(), - "hs_office_bankaccount#Fourthe.G..admin"); + "hs_office_bankaccount#DE02200505501015871393:ADMIN"); } @Test - public void partnerAdmin_canNotUpdateRelatedDebitor() { + public void partnerAgent_canNotUpdateRelatedDebitor() { // given context("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eighth", "Fourth", "eig"); assertThatDebitorIsVisibleForUserWithRole( givenDebitor, - "hs_office_partner#10004:Fourthe.G.-forthcontact.admin"); - assertThatDebitorActuallyInDatabase(givenDebitor); + "hs_office_relation#HostsharingeG-with-PARTNER-FourtheG:AGENT", true); + assertThatDebitorActuallyInDatabase(givenDebitor, true); // when final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_partner#10004:Fourthe.G.-forthcontact.admin"); + context("superuser-alex@hostsharing.net", "hs_office_relation#HostsharingeG-with-PARTNER-FourtheG:AGENT"); givenDebitor.setVatId("NEW-VAT-ID"); - return debitorRepo.save(givenDebitor); + return toCleanup(debitorRepo.save(givenDebitor)); }); // then - result.assertExceptionWithRootCauseMessage(JpaSystemException.class, - "[403] Subject ", " is not allowed to update hs_office_debitor uuid"); + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_debitor uuid"); } @Test @@ -428,35 +462,46 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // given context("superuser-alex@hostsharing.net"); final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "ninth", "Fourth", "nin"); - assertThatDebitorIsVisibleForUserWithRole( - givenDebitor, - "hs_office_contact#ninthcontact.admin"); - assertThatDebitorActuallyInDatabase(givenDebitor); + assertThatDebitorActuallyInDatabase(givenDebitor, true); + assertThatDebitorIsVisibleForUserWithRole(givenDebitor, "hs_office_contact#ninthcontact:ADMIN", false); // when final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin"); + context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact:ADMIN"); givenDebitor.setVatId("NEW-VAT-ID"); - return debitorRepo.save(givenDebitor); + final HsOfficeDebitorEntity entity = debitorRepo.save(givenDebitor); + return toCleanup(entity.load()); }); // then - result.assertExceptionWithRootCauseMessage(JpaSystemException.class, - "[403] Subject ", " is not allowed to update hs_office_debitor uuid"); + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "ERROR: [403]", + "is not allowed to update hs_office_debitor uuid"); } - private void assertThatDebitorActuallyInDatabase(final HsOfficeDebitorEntity saved) { + private void assertThatDebitorActuallyInDatabase(final HsOfficeDebitorEntity saved, final boolean withPartner) { final var found = debitorRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().isNotSameAs(saved) - .extracting(Object::toString).isEqualTo(saved.toString()); + assertThat(found).isNotEmpty(); + found.ifPresent(foundEntity -> { + em.refresh(foundEntity); + Hibernate.initialize(foundEntity); + assertThat(foundEntity).isNotSameAs(saved); + if (withPartner) { + assertThat(foundEntity.getPartner()).isNotNull(); + } + assertThat(foundEntity.getDebitorRel()).extracting(HsOfficeRelation::toString) + .isEqualTo(saved.getDebitorRel().toString()); + }); } private void assertThatDebitorIsVisibleForUserWithRole( final HsOfficeDebitorEntity entity, - final String assumedRoles) { + final String assumedRoles, + final boolean withPartner) { jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", assumedRoles); - assertThatDebitorActuallyInDatabase(entity); + assertThatDebitorActuallyInDatabase(entity, withPartner); }).assertSuccessful(); } @@ -495,14 +540,14 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { } @Test - public void relatedPerson_canNotDeleteTheirRelatedDebitor() { + public void debitorAgent_canViewButNotDeleteTheirRelatedDebitor() { // given context("superuser-alex@hostsharing.net", null); final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eleventh", "Fourth", "ele"); // when final var result = jpaAttempt.transacted(() -> { - context("person-Fourthe.G.@example.com"); + context("superuser-alex@hostsharing.net", "hs_office_relation#FourtheG-with-DEBITOR-FourtheG:ADMIN"); assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent(); debitorRepo.deleteByUuid(givenDebitor.getUuid()); @@ -522,13 +567,9 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { public void deletingADebitorAlsoDeletesRelatedRolesAndGrants() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); - final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); - final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "twelfth", "Fourth", "twe"); - assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created") - .isEqualTo(initialRoleNames.length + 5); - assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created") - .isEqualTo(initialGrantNames.length + 17); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "twelfth", "Fourth", "twi"); // when final var result = jpaAttempt.transacted(() -> { @@ -539,8 +580,8 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); } } @@ -548,9 +589,8 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'defaultprefix' + from tx_journal_v where targettable = 'hs_office_debitor'; """); @@ -559,31 +599,39 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating debitor test-data FirstGmbH-firstcontact, hs_office_debitor, INSERT]", - "[creating debitor test-data Seconde.K.-secondcontact, hs_office_debitor, INSERT]"); + "[creating debitor test-data, hs_office_debitor, INSERT, fir]", + "[creating debitor test-data, hs_office_debitor, INSERT, sec]", + "[creating debitor test-data, hs_office_debitor, INSERT, thi]"); } private HsOfficeDebitorEntity givenSomeTemporaryDebitor( - final String partner, - final String contact, - final String bankAccount, + final String partnerName, + final String contactCaption, + final String bankAccountHolder, final String defaultPrefix) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partner).get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0); + final var givenPartner = one(partnerRepo.findPartnerByOptionalNameLike(partnerName)); + final var givenPartnerPerson = givenPartner.getPartnerRel().getHolder(); + final var givenContact = one(contactrealRepo.findContactByOptionalCaptionLike(contactCaption)); final var givenBankAccount = - bankAccount != null ? bankAccountRepo.findByOptionalHolderLike(bankAccount).get(0) : null; + bankAccountHolder != null ? one(bankAccountRepo.findByOptionalHolderLike(bankAccountHolder)) : null; final var newDebitor = HsOfficeDebitorEntity.builder() - .debitorNumberSuffix((byte)20) .partner(givenPartner) - .billingContact(givenContact) + .debitorNumberSuffix("20") + .debitorRel(HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.DEBITOR) + .anchor(givenPartnerPerson) + .holder(givenPartnerPerson) + .contact(givenContact) + .build()) .refundBankAccount(givenBankAccount) .defaultPrefix(defaultPrefix) .billable(true) .build(); - return debitorRepo.save(newDebitor); + final HsOfficeDebitorEntity entity = debitorRepo.save(newDebitor); + return toCleanup(entity.load()); }).assertSuccessful().returnedValue(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java index 36b3d534..a3df1026 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java @@ -1,19 +1,25 @@ package net.hostsharing.hsadminng.hs.office.debitor; import lombok.experimental.UtilityClass; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; - -import static net.hostsharing.hsadminng.hs.office.contact.TestHsOfficeContact.TEST_CONTACT; +import static net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealTestEntity.TEST_REAL_CONTACT; import static net.hostsharing.hsadminng.hs.office.partner.TestHsOfficePartner.TEST_PARTNER; @UtilityClass public class TestHsOfficeDebitor { - public byte DEFAULT_DEBITOR_SUFFIX = 0; + public String DEFAULT_DEBITOR_SUFFIX = "00"; public static final HsOfficeDebitorEntity TEST_DEBITOR = HsOfficeDebitorEntity.builder() .debitorNumberSuffix(DEFAULT_DEBITOR_SUFFIX) + .debitorRel(HsOfficeRelationRealEntity.builder() + .holder(HsOfficePersonEntity.builder().build()) + .anchor(HsOfficePersonEntity.builder().build()) + .contact(TEST_REAL_CONTACT) + .build()) .partner(TEST_PARTNER) - .billingContact(TEST_CONTACT) + .defaultPrefix("abc") .build(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java index 7afafaff..f0e108dc 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java @@ -1,14 +1,13 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; @@ -23,9 +22,10 @@ import jakarta.persistence.PersistenceContext; import java.time.LocalDate; import java.util.UUID; -import static net.hostsharing.hsadminng.hs.office.membership.HsOfficeReasonForTermination.NONE; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipStatus.ACTIVE; +import static net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipStatus.CANCELLED; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.*; @@ -34,9 +34,9 @@ import static org.hamcrest.Matchers.*; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeMembershipControllerAcceptanceTest { +class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCleanup { - private static String TEMP_MEMBER_NUMBER_SUFFIX = "90"; + private static final String TEMP_MEMBER_NUMBER_SUFFIX = "90"; @LocalServerPort private Integer port; @@ -50,9 +50,6 @@ class HsOfficeMembershipControllerAcceptanceTest { @Autowired HsOfficeMembershipRepository membershipRepo; - @Autowired - HsOfficeDebitorRepository debitorRepo; - @Autowired HsOfficePartnerRepository partnerRepo; @@ -63,7 +60,6 @@ class HsOfficeMembershipControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "Membership:F(Find)" }) class ListMemberships { @Test @@ -81,31 +77,28 @@ class HsOfficeMembershipControllerAcceptanceTest { .body("", lenientlyEquals(""" [ { - "partner": { "person": { "tradeName": "First GmbH" } }, - "mainDebitor": { "debitorNumber": 1000111 }, + "partner": { "partnerNumber": 10001 }, "memberNumber": 1000101, "memberNumberSuffix": "01", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" }, { - "partner": { "person": { "tradeName": "Second e.K." } }, - "mainDebitor": { "debitorNumber": 1000212 }, + "partner": { "partnerNumber": 10002 }, "memberNumber": 1000202, "memberNumberSuffix": "02", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" }, { - "partner": { "person": { "tradeName": "Third OHG" } }, - "mainDebitor": { "debitorNumber": 1000313 }, + "partner": { "partnerNumber": 10003 }, "memberNumber": 1000303, "memberNumberSuffix": "03", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" } ] """)); @@ -113,7 +106,7 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Test - void globalAdmin_canViewMembershipsByPartnerUuid() throws JSONException { + void globalAdmin_canViewMembershipsByPartnerUuid() { context.define("superuser-alex@hostsharing.net"); final var partner = partnerRepo.findPartnerByPartnerNumber(10001); @@ -131,13 +124,12 @@ class HsOfficeMembershipControllerAcceptanceTest { .body("", lenientlyEquals(""" [ { - "partner": { "person": { "tradeName": "First GmbH" } }, - "mainDebitor": { "debitorNumber": 1000111 }, + "partner": { "partnerNumber": 10001 }, "memberNumber": 1000101, "memberNumberSuffix": "01", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" } ] """)); @@ -145,7 +137,7 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Test - void globalAdmin_canViewMembershipsByMemberNumber() throws JSONException { + void globalAdmin_canViewMembershipsByMemberNumber() { RestAssured // @formatter:off .given() @@ -160,13 +152,12 @@ class HsOfficeMembershipControllerAcceptanceTest { .body("", lenientlyEquals(""" [ { - "partner": { "person": { "tradeName": "Second e.K." } }, - "mainDebitor": { "debitorNumber": 1000212 }, + "partner": { "partnerNumber": 10002 }, "memberNumber": 1000202, "memberNumberSuffix": "02", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" } ] """)); @@ -175,7 +166,6 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Nested - @Accepts({ "Membership:C(Create)" }) class AddMembership { @Test @@ -183,7 +173,6 @@ class HsOfficeMembershipControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0); final var givenMemberSuffix = TEMP_MEMBER_NUMBER_SUFFIX; final var expectedMemberNumber = Integer.parseInt(givenPartner.getPartnerNumber() + TEMP_MEMBER_NUMBER_SUFFIX); @@ -194,12 +183,11 @@ class HsOfficeMembershipControllerAcceptanceTest { .body(""" { "partnerUuid": "%s", - "mainDebitorUuid": "%s", "memberNumberSuffix": "%s", "validFrom": "2022-10-13", "membershipFeeBillable": "true" } - """.formatted(givenPartner.getUuid(), givenDebitor.getUuid(), givenMemberSuffix)) + """.formatted(givenPartner.getUuid(), givenMemberSuffix)) .port(port) .when() .post("http://localhost/api/hs/office/memberships") @@ -207,9 +195,7 @@ class HsOfficeMembershipControllerAcceptanceTest { .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("mainDebitor.debitorNumber", is(givenDebitor.getDebitorNumber())) - .body("mainDebitor.debitorNumberSuffix", is((int) givenDebitor.getDebitorNumberSuffix())) - .body("partner.person.tradeName", is("Third OHG")) + .body("partner.partnerNumber", is(10003)) .body("memberNumber", is(expectedMemberNumber)) .body("memberNumberSuffix", is(givenMemberSuffix)) .body("validFrom", is("2022-10-13")) @@ -226,7 +212,6 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Nested - @Accepts({ "Membership:R(Read)" }) class GetMembership { @Test @@ -245,19 +230,17 @@ class HsOfficeMembershipControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "partner": { "person": { "tradeName": "First GmbH" } }, - "mainDebitor": { "debitorNumber": 1000111 }, + "partner": { "partnerNumber": 10001 }, "memberNumber": 1000101, "memberNumberSuffix": "01", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" } """)); // @formatter:on } @Test - @Accepts({ "Membership:X(Access Control)" }) void normalUser_canNotGetUnrelatedMembership() { context.define("superuser-alex@hostsharing.net"); final var givenMembershipUuid = membershipRepo.findMembershipByMemberNumber(1000101).getUuid(); @@ -273,15 +256,14 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Test - @Accepts({ "Membership:X(Access Control)" }) - void debitorAgentUser_canGetRelatedMembership() { + void parnerRelAgent_canGetRelatedMembership() { context.define("superuser-alex@hostsharing.net"); final var givenMembershipUuid = membershipRepo.findMembershipByMemberNumber(1000303).getUuid(); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "hs_office_debitor#1000313:ThirdOHG-thirdcontact.agent") + .header("assumed-roles", "hs_office_relation#HostsharingeG-with-PARTNER-ThirdOHG:AGENT") .port(port) .when() .get("http://localhost/api/hs/office/memberships/" + givenMembershipUuid) @@ -290,30 +272,25 @@ class HsOfficeMembershipControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "partner": { "person": { "tradeName": "Third OHG" } }, - "mainDebitor": { - "debitorNumber": 1000313, - "billingContact": { "label": "third contact" } - }, + "partner": { "partnerNumber": 10003 }, "memberNumber": 1000303, "memberNumberSuffix": "03", "validFrom": "2022-10-01", "validTo": null, - "reasonForTermination": "NONE" + "status": "ACTIVE" } """)); // @formatter:on } } @Nested - @Accepts({ "Membership:U(Update)" }) class PatchMembership { @Test void globalAdmin_canPatchValidToOfArbitraryMembership() { context.define("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); + final var givenMembership = givenSomeTemporaryMembershipBessler("First"); final var location = RestAssured // @formatter:off .given() @@ -322,7 +299,7 @@ class HsOfficeMembershipControllerAcceptanceTest { .body(""" { "validTo": "2023-12-31", - "reasonForTermination": "CANCELLATION" + "status": "CANCELLED" } """) .port(port) @@ -332,112 +309,67 @@ class HsOfficeMembershipControllerAcceptanceTest { .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("partner.person.tradeName", is(givenMembership.getPartner().getPerson().getTradeName())) - .body("mainDebitor.debitorNumber", is(givenMembership.getMainDebitor().getDebitorNumber())) - .body("mainDebitor.debitorNumberSuffix", is((int) givenMembership.getMainDebitor().getDebitorNumberSuffix())) - .body("mainDebitor.debitorNumberSuffix", is((int) givenMembership.getMainDebitor().getDebitorNumberSuffix())) + .body("partner.partnerNumber", is(givenMembership.getPartner().getPartnerNumber())) .body("memberNumberSuffix", is(givenMembership.getMemberNumberSuffix())) .body("validFrom", is("2022-11-01")) .body("validTo", is("2023-12-31")) - .body("reasonForTermination", is("CANCELLATION")); + .body("status", is("CANCELLED")); // @formatter:on // finally, the Membership is actually updated assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getPartner().toShortString()).isEqualTo("LP First GmbH"); - assertThat(mandate.getMainDebitor().toString()).isEqualTo(givenMembership.getMainDebitor().toString()); + assertThat(mandate.getPartner().toShortString()).isEqualTo("P-10001"); assertThat(mandate.getMemberNumberSuffix()).isEqualTo(givenMembership.getMemberNumberSuffix()); assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2024-01-01)"); - assertThat(mandate.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.CANCELLATION); + assertThat(mandate.getStatus()).isEqualTo(CANCELLED); return true; }); } @Test - void globalAdmin_canPatchMainDebitorOfArbitraryMembership() { + void partnerRelAdmin_canPatchValidityOfRelatedMembership() { - context.define("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); - final var givenNewMainDebitor = debitorRepo.findDebitorByDebitorNumber(1000313).get(0); + // given + final var givenPartnerAdmin = "hs_office_relation#HostsharingeG-with-PARTNER-FirstGmbH:ADMIN"; + context.define("superuser-alex@hostsharing.net", givenPartnerAdmin); + final var givenMembership = givenSomeTemporaryMembershipBessler("First"); + // when RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", givenPartnerAdmin) .contentType(ContentType.JSON) .body(""" { - "mainDebitorUuid": "%s" - } - """.formatted(givenNewMainDebitor.getUuid())) - .port(port) - .when() - .patch("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid()) - .then().log().all().assertThat() - .statusCode(200) - .contentType(ContentType.JSON) - .body("uuid", isUuidValid()) - .body("partner.person.tradeName", is(givenMembership.getPartner().getPerson().getTradeName())) - .body("mainDebitor.debitorNumber", is(1000313)) - .body("memberNumberSuffix", is(givenMembership.getMemberNumberSuffix())) - .body("validFrom", is("2022-11-01")) - .body("validTo", nullValue()) - .body("reasonForTermination", is("NONE")); - // @formatter:on - - // finally, the Membership is actually updated - assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get() - .matches(mandate -> { - assertThat(mandate.getPartner().toShortString()).isEqualTo("LP First GmbH"); - assertThat(mandate.getMainDebitor().toString()).isEqualTo(givenMembership.getMainDebitor().toString()); - assertThat(mandate.getMemberNumberSuffix()).isEqualTo(givenMembership.getMemberNumberSuffix()); - assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,)"); - assertThat(mandate.getReasonForTermination()).isEqualTo(NONE); - return true; - }); - } - - @Test - void partnerAgent_canViewButNotPatchValidityOfRelatedMembership() { - - context.define("superuser-alex@hostsharing.net", "hs_office_partner#10001:FirstGmbH-firstcontact.agent"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); - - final var location = RestAssured // @formatter:off - .given() - .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "hs_office_partner#10001:FirstGmbH-firstcontact.agent") - .contentType(ContentType.JSON) - .body(""" - { - "validTo": "2023-12-31", - "reasonForTermination": "CANCELLATION" + "validTo": "2024-01-01", + "status": "CANCELLED" } """) .port(port) .when() .patch("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid()) .then().assertThat() - .statusCode(403); // @formatter:on + .statusCode(200); // @formatter:on // finally, the Membership is actually updated assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,)"); - assertThat(mandate.getReasonForTermination()).isEqualTo(NONE); + assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2024-01-02)"); + assertThat(mandate.getStatus()).isEqualTo(CANCELLED); return true; }); } } @Nested - @Accepts({ "Membership:D(Delete)" }) class DeleteMembership { @Test void globalAdmin_canDeleteArbitraryMembership() { context.define("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); + final var givenMembership = givenSomeTemporaryMembershipBessler("First"); RestAssured // @formatter:off .given() @@ -453,15 +385,14 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Test - @Accepts({ "Membership:X(Access Control)" }) void partnerAgentUser_canNotDeleteRelatedMembership() { context.define("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); + final var givenMembership = givenSomeTemporaryMembershipBessler("First"); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "hs_office_partner#FirstGmbH-firstcontact.agent") + .header("assumed-roles", "hs_office_relation#HostsharingeG-with-PARTNER-FirstGmbH:AGENT") .port(port) .when() .delete("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid()) @@ -473,10 +404,9 @@ class HsOfficeMembershipControllerAcceptanceTest { } @Test - @Accepts({ "Membership:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedMembership() { context.define("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembershipBessler(); + final var givenMembership = givenSomeTemporaryMembershipBessler("First"); RestAssured // @formatter:off .given() @@ -492,18 +422,16 @@ class HsOfficeMembershipControllerAcceptanceTest { } } - private HsOfficeMembershipEntity givenSomeTemporaryMembershipBessler() { + private HsOfficeMembershipEntity givenSomeTemporaryMembershipBessler(final String partnerName) { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partnerName).get(0); final var newMembership = HsOfficeMembershipEntity.builder() .uuid(UUID.randomUUID()) .partner(givenPartner) - .mainDebitor(givenDebitor) .memberNumberSuffix(TEMP_MEMBER_NUMBER_SUFFIX) .validity(Range.closedInfinite(LocalDate.parse("2022-11-01"))) - .reasonForTermination(NONE) + .status(ACTIVE) .membershipFeeBillable(true) .build(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerRestTest.java index 63ea7306..7c62859b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerRestTest.java @@ -2,7 +2,6 @@ package net.hostsharing.hsadminng.hs.office.membership; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionRepository; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; import net.hostsharing.hsadminng.mapper.Mapper; import org.junit.jupiter.api.BeforeEach; @@ -28,7 +27,6 @@ import java.util.UUID; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -76,7 +74,6 @@ public class HsOfficeMembershipControllerRestTest { .content(""" { "partnerUuid": null, - "mainDebitorUuid": "%s", "memberNumberSuffix": "01", "validFrom": "2022-10-13", "membershipFeeBillable": "true" @@ -88,33 +85,8 @@ public class HsOfficeMembershipControllerRestTest { .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is("[partnerUuid must not be null but is \"null\"]"))); - } - - @Test - void respondBadRequest_ifDebitorUuidIsMissing() throws Exception { - - // when - mockMvc.perform(MockMvcRequestBuilders - .post("/api/hs/office/memberships") - .header("current-user", "superuser-alex@hostsharing.net") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "partnerUuid": "%s", - "mainDebitorUuid": null, - "memberNumberSuffix": "01", - "validFrom": "2022-10-13", - "membershipFeeBillable": "true" - } - """.formatted(UUID.randomUUID())) - .accept(MediaType.APPLICATION_JSON)) - - // then - .andExpect(status().is4xxClientError()) - .andExpect(jsonPath("statusCode", is(400))) - .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is("[mainDebitorUuid must not be null but is \"null\"]"))); + // FYI: the brackets around the message are here because it's actually an array, in this case of size 1 + .andExpect(jsonPath("message", is("ERROR: [400] [partnerUuid must not be null but is \"null\"]"))); } @Test @@ -122,9 +94,7 @@ public class HsOfficeMembershipControllerRestTest { // given final var givenPartnerUuid = UUID.randomUUID(); - final var givenMainDebitorUuid = UUID.randomUUID(); when(em.find(HsOfficePartnerEntity.class, givenPartnerUuid)).thenReturn(null); - when(em.find(HsOfficeDebitorEntity.class, givenMainDebitorUuid)).thenReturn(mock(HsOfficeDebitorEntity.class)); // when mockMvc.perform(MockMvcRequestBuilders @@ -134,51 +104,18 @@ public class HsOfficeMembershipControllerRestTest { .content(""" { "partnerUuid": "%s", - "mainDebitorUuid": "%s", "memberNumberSuffix": "01", "validFrom": "2022-10-13", "membershipFeeBillable": "true" } - """.formatted(givenPartnerUuid, givenMainDebitorUuid)) + """.formatted(givenPartnerUuid)) .accept(MediaType.APPLICATION_JSON)) // then .andExpect(status().is4xxClientError()) .andExpect(jsonPath("statusCode", is(400))) .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is("Unable to find Partner with uuid " + givenPartnerUuid))); - } - - @Test - void respondBadRequest_ifAnyGivenDebitorUuidCannotBeFound() throws Exception { - - // given - final var givenPartnerUuid = UUID.randomUUID(); - final var givenMainDebitorUuid = UUID.randomUUID(); - when(em.find(HsOfficePartnerEntity.class, givenPartnerUuid)).thenReturn(mock(HsOfficePartnerEntity.class)); - when(em.find(HsOfficeDebitorEntity.class, givenMainDebitorUuid)).thenReturn(null); - - // when - mockMvc.perform(MockMvcRequestBuilders - .post("/api/hs/office/memberships") - .header("current-user", "superuser-alex@hostsharing.net") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "partnerUuid": "%s", - "mainDebitorUuid": "%s", - "memberNumberSuffix": "01", - "validFrom": "2022-10-13", - "membershipFeeBillable": "true" - } - """.formatted(givenPartnerUuid, givenMainDebitorUuid)) - .accept(MediaType.APPLICATION_JSON)) - - // then - .andExpect(status().is4xxClientError()) - .andExpect(jsonPath("statusCode", is(400))) - .andExpect(jsonPath("statusPhrase", is("Bad Request"))) - .andExpect(jsonPath("message", is("Unable to find Debitor with uuid " + givenMainDebitorUuid))); + .andExpect(jsonPath("message", is("ERROR: [400] Unable to find Partner by uuid: " + givenPartnerUuid))); } @ParameterizedTest @@ -193,12 +130,11 @@ public class HsOfficeMembershipControllerRestTest { .content(""" { "partnerUuid": "%s", - "mainDebitorUuid": "%s", %s "validFrom": "2022-10-13", "membershipFeeBillable": "true" } - """.formatted(UUID.randomUUID(), UUID.randomUUID(), testCase.memberNumberSuffixEntry)) + """.formatted(UUID.randomUUID(), testCase.memberNumberSuffixEntry)) .accept(MediaType.APPLICATION_JSON)) // then diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcherUnitTest.java index ee4944c1..2e739e7f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityPatcherUnitTest.java @@ -1,11 +1,11 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeReasonForTerminationResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipStatusResource; import net.hostsharing.hsadminng.mapper.Mapper; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,7 +17,6 @@ import java.time.LocalDate; import java.util.UUID; import java.util.stream.Stream; -import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.TEST_DEBITOR; import static net.hostsharing.hsadminng.hs.office.partner.TestHsOfficePartner.TEST_PARTNER; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import static org.mockito.ArgumentMatchers.any; @@ -32,7 +31,6 @@ class HsOfficeMembershipEntityPatcherUnitTest extends PatchUnitTestBase< > { private static final UUID INITIAL_MEMBERSHIP_UUID = UUID.randomUUID(); - private static final UUID PATCHED_MAIN_DEBITOR_UUID = UUID.randomUUID(); private static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-04-15"); private static final LocalDate PATCHED_VALID_TO = LocalDate.parse("2022-12-31"); @@ -56,7 +54,6 @@ class HsOfficeMembershipEntityPatcherUnitTest extends PatchUnitTestBase< protected HsOfficeMembershipEntity newInitialEntity() { final var entity = new HsOfficeMembershipEntity(); entity.setUuid(INITIAL_MEMBERSHIP_UUID); - entity.setMainDebitor(TEST_DEBITOR); entity.setPartner(TEST_PARTNER); entity.setValidity(Range.closedInfinite(GIVEN_VALID_FROM)); entity.setMembershipFeeBillable(GIVEN_MEMBERSHIP_FEE_BILLABLE); @@ -70,30 +67,23 @@ class HsOfficeMembershipEntityPatcherUnitTest extends PatchUnitTestBase< @Override protected HsOfficeMembershipEntityPatcher createPatcher(final HsOfficeMembershipEntity membership) { - return new HsOfficeMembershipEntityPatcher(em, mapper, membership); + return new HsOfficeMembershipEntityPatcher(mapper, membership); } @Override protected Stream propertyTestDescriptors() { return Stream.of( - new JsonNullableProperty<>( - "debitor", - HsOfficeMembershipPatchResource::setMainDebitorUuid, - PATCHED_MAIN_DEBITOR_UUID, - HsOfficeMembershipEntity::setMainDebitor, - newDebitor(PATCHED_MAIN_DEBITOR_UUID)) - .notNullable(), new JsonNullableProperty<>( "valid", HsOfficeMembershipPatchResource::setValidTo, PATCHED_VALID_TO, HsOfficeMembershipEntity::setValidTo), new SimpleProperty<>( - "reasonForTermination", - HsOfficeMembershipPatchResource::setReasonForTermination, - HsOfficeReasonForTerminationResource.CANCELLATION, - HsOfficeMembershipEntity::setReasonForTermination, - HsOfficeReasonForTermination.CANCELLATION) + "status", + HsOfficeMembershipPatchResource::setStatus, + HsOfficeMembershipStatusResource.CANCELLED, + HsOfficeMembershipEntity::setStatus, + HsOfficeMembershipStatus.CANCELLED) .notNullable(), new JsonNullableProperty<>( "membershipFeeBillable", @@ -102,10 +92,4 @@ class HsOfficeMembershipEntityPatcherUnitTest extends PatchUnitTestBase< HsOfficeMembershipEntity::setMembershipFeeBillable) ); } - - private static HsOfficeDebitorEntity newDebitor(final UUID uuid) { - final var newDebitor = new HsOfficeDebitorEntity(); - newDebitor.setUuid(uuid); - return newDebitor; - } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java index b1815755..b2e5bb68 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipEntityUnitTest.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; import org.junit.jupiter.api.Test; @@ -9,7 +9,6 @@ import java.lang.reflect.InvocationTargetException; import java.time.LocalDate; import java.util.Arrays; -import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.TEST_DEBITOR; import static net.hostsharing.hsadminng.hs.office.partner.TestHsOfficePartner.TEST_PARTNER; import static org.assertj.core.api.Assertions.assertThat; @@ -20,14 +19,13 @@ class HsOfficeMembershipEntityUnitTest { final HsOfficeMembershipEntity givenMembership = HsOfficeMembershipEntity.builder() .memberNumberSuffix("01") .partner(TEST_PARTNER) - .mainDebitor(TEST_DEBITOR) .validity(Range.closedInfinite(GIVEN_VALID_FROM)) .build(); @Test void toStringContainsAllProps() { final var result = givenMembership.toString(); - assertThat(result).isEqualTo("Membership(M-1000101, LP Test Ltd., D-1000100, [2020-01-01,))"); + assertThat(result).isEqualTo("Membership(M-1000101, P-10001, [2020-01-01,))"); } @Test @@ -64,27 +62,27 @@ class HsOfficeMembershipEntityUnitTest { } @Test - void getValidtyIfNull() { + void getEmptyValidtyIfNull() { givenMembership.setValidity(null); final var result = givenMembership.getValidity(); - assertThat(result).isEqualTo(Range.infinite(LocalDate.class)); + assertThat(result.isEmpty()).isTrue(); } @Test - void initializesReasonForTerminationInPrePersistIfNull() throws Exception { + void initializesStatusInPrePersistIfNull() throws Exception { final var givenUninitializedMembership = new HsOfficeMembershipEntity(); - assertThat(givenUninitializedMembership.getReasonForTermination()).as("precondition failed").isNull(); + assertThat(givenUninitializedMembership.getStatus()).as("precondition failed").isNull(); invokePrePersist(givenUninitializedMembership); - assertThat(givenUninitializedMembership.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.NONE); + assertThat(givenUninitializedMembership.getStatus()).isEqualTo(HsOfficeMembershipStatus.INVALID); } @Test - void doesNotOverwriteReasonForTerminationInPrePersistIfNotNull() throws Exception { - givenMembership.setReasonForTermination(HsOfficeReasonForTermination.CANCELLATION); + void doesNotOverwriteStatusInPrePersistIfNotNull() throws Exception { + givenMembership.setStatus(HsOfficeMembershipStatus.CANCELLED); invokePrePersist(givenMembership); - assertThat(givenMembership.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.CANCELLATION); + assertThat(givenMembership.getStatus()).isEqualTo(HsOfficeMembershipStatus.CANCELLED); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java index af62541c..6e013be2 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -1,16 +1,14 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -24,18 +22,16 @@ import jakarta.persistence.PersistenceContext; import jakarta.servlet.http.HttpServletRequest; import java.time.LocalDate; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeMembershipRepository membershipRepo; @@ -61,8 +57,6 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { @MockBean HttpServletRequest request; - Set tempEntities = new HashSet<>(); - @Nested class CreateMembership { @@ -72,18 +66,16 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net"); final var count = membershipRepo.count(); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); // when final var result = attempt(em, () -> { - final var newMembership = toCleanup(HsOfficeMembershipEntity.builder() + final var newMembership = HsOfficeMembershipEntity.builder() .memberNumberSuffix("11") .partner(givenPartner) - .mainDebitor(givenDebitor) .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) .membershipFeeBillable(true) - .build()); - return membershipRepo.save(newMembership); + .build(); + return toCleanup(membershipRepo.save(newMembership).load()); }); // then @@ -97,76 +89,61 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("GmbH-firstcontact", "")) + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() .map(s -> s.replace("hs_office_", "")) .toList(); // when attempt(em, () -> { final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); - final var newMembership = toCleanup(HsOfficeMembershipEntity.builder() - .memberNumberSuffix("07") + final var newMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("17") .partner(givenPartner) - .mainDebitor(givenDebitor) .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) .membershipFeeBillable(true) - .build()); - return membershipRepo.save(newMembership); - }); + .build(); + return toCleanup(membershipRepo.save(newMembership)); + }).assertSuccessful(); // then final var all = rawRoleRepo.findAll(); - assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_office_membership#1000107:FirstGmbH-firstcontact.admin", - "hs_office_membership#1000107:FirstGmbH-firstcontact.agent", - "hs_office_membership#1000107:FirstGmbH-firstcontact.guest", - "hs_office_membership#1000107:FirstGmbH-firstcontact.owner", - "hs_office_membership#1000107:FirstGmbH-firstcontact.tenant")); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("GmbH-firstcontact", "")) + "hs_office_membership#M-1000117:OWNER", + "hs_office_membership#M-1000117:ADMIN", + "hs_office_membership#M-1000117:AGENT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) .map(s -> s.replace("hs_office_", "")) .containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, + // insert + "{ grant perm:membership#M-1000117:INSERT>coopassetstransaction to role:membership#M-1000117:ADMIN by system and assume }", + "{ grant perm:membership#M-1000117:INSERT>coopsharestransaction to role:membership#M-1000117:ADMIN by system and assume }", // owner - "{ grant perm * on membership#1000107:First to role membership#1000107:First.owner by system and assume }", - "{ grant role membership#1000107:First.owner to role global#global.admin by system and assume }", + "{ grant perm:membership#M-1000117:DELETE to role:membership#M-1000117:ADMIN by system and assume }", + "{ grant role:membership#M-1000117:OWNER to user:superuser-alex@hostsharing.net by membership#M-1000117:OWNER and assume }", // admin - "{ grant perm edit on membership#1000107:First to role membership#1000107:First.admin by system and assume }", - "{ grant role membership#1000107:First.admin to role membership#1000107:First.owner by system and assume }", + "{ grant perm:membership#M-1000117:UPDATE to role:membership#M-1000117:ADMIN by system and assume }", + "{ grant role:membership#M-1000117:ADMIN to role:membership#M-1000117:OWNER by system and assume }", + "{ grant role:membership#M-1000117:ADMIN to role:relation#HostsharingeG-with-PARTNER-FirstGmbH:ADMIN by system and assume }", // agent - "{ grant role membership#1000107:First.agent to role membership#1000107:First.admin by system and assume }", - "{ grant role partner#10001:First.tenant to role membership#1000107:First.agent by system and assume }", - "{ grant role membership#1000107:First.agent to role debitor#1000111:First.admin by system and assume }", - "{ grant role membership#1000107:First.agent to role partner#10001:First.admin by system and assume }", - "{ grant role debitor#1000111:First.tenant to role membership#1000107:First.agent by system and assume }", + "{ grant perm:membership#M-1000117:SELECT to role:membership#M-1000117:AGENT by system and assume }", + "{ grant role:membership#M-1000117:AGENT to role:membership#M-1000117:ADMIN by system and assume }", - // tenant - "{ grant role membership#1000107:First.tenant to role membership#1000107:First.agent by system and assume }", - "{ grant role partner#10001:First.guest to role membership#1000107:First.tenant by system and assume }", - "{ grant role debitor#1000111:First.guest to role membership#1000107:First.tenant by system and assume }", - "{ grant role membership#1000107:First.tenant to role debitor#1000111:First.agent by system and assume }", - - "{ grant role membership#1000107:First.tenant to role partner#10001:First.agent by system and assume }", - - // guest - "{ grant perm view on membership#1000107:First to role membership#1000107:First.guest by system and assume }", - "{ grant role membership#1000107:First.guest to role membership#1000107:First.tenant by system and assume }", - "{ grant role membership#1000107:First.guest to role partner#10001:First.tenant by system and assume }", - "{ grant role membership#1000107:First.guest to role debitor#1000111:First.tenant by system and assume }", + // referrer + "{ grant role:membership#M-1000117:AGENT to role:relation#HostsharingeG-with-PARTNER-FirstGmbH:AGENT by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-FirstGmbH:TENANT to role:membership#M-1000117:AGENT by system and assume }", null)); } private void assertThatMembershipIsPersisted(final HsOfficeMembershipEntity saved) { final var found = membershipRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()) ; } } @@ -184,9 +161,9 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseMembershipsAreReturned( result, - "Membership(M-1000101, LP First GmbH, D-1000111, [2022-10-01,), NONE)", - "Membership(M-1000202, LP Second e.K., D-1000212, [2022-10-01,), NONE)", - "Membership(M-1000303, IF Third OHG, D-1000313, [2022-10-01,), NONE)"); + "Membership(M-1000101, P-10001, [2022-10-01,), ACTIVE)", + "Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)", + "Membership(M-1000303, P-10003, [2022-10-01,), ACTIVE)"); } @Test @@ -200,7 +177,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseMembershipsAreReturned(result, - "Membership(M-1000101, LP First GmbH, D-1000111, [2022-10-01,), NONE)"); + "Membership(M-1000101, P-10001, [2022-10-01,), ACTIVE)"); } @Test @@ -215,7 +192,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { assertThat(result) .isNotNull() .extracting(Object::toString) - .isEqualTo("Membership(M-1000202, LP Second e.K., D-1000212, [2022-10-01,), NONE)"); + .isEqualTo("Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)"); } } @@ -226,10 +203,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { public void globalAdmin_canUpdateValidityOfArbitraryMembership() { // given context("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembership("First", "First"); - assertThatMembershipIsVisibleForUserWithRole( - givenMembership, - "hs_office_debitor#1000111:FirstGmbH-firstcontact.admin"); + final var givenMembership = givenSomeTemporaryMembership("First", "11"); assertThatMembershipExistsAndIsAccessibleToCurrentContext(givenMembership); final var newValidityEnd = LocalDate.now(); @@ -239,32 +213,32 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net"); givenMembership.setValidity(Range.closedOpen( givenMembership.getValidity().lower(), newValidityEnd)); - givenMembership.setReasonForTermination(HsOfficeReasonForTermination.CANCELLATION); - return toCleanup(membershipRepo.save(givenMembership)); + givenMembership.setStatus(HsOfficeMembershipStatus.CANCELLED); + final HsOfficeMembershipEntity entity = membershipRepo.save(givenMembership); + return toCleanup(entity.load()); }); // then result.assertSuccessful(); - - membershipRepo.deleteByUuid(givenMembership.getUuid()); } @Test - public void debitorAdmin_canViewButNotUpdateRelatedMembership() { + public void membershipAgent_canViewButNotUpdateRelatedMembership() { // given context("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembership("First", "First"); - assertThatMembershipIsVisibleForUserWithRole( - givenMembership, - "hs_office_debitor#1000111:FirstGmbH-firstcontact.admin"); + final var givenMembership = givenSomeTemporaryMembership("First", "13"); assertThatMembershipExistsAndIsAccessibleToCurrentContext(givenMembership); + assertThatMembershipIsVisibleForRole( + givenMembership, + "hs_office_membership#M-1000113:AGENT"); final var newValidityEnd = LocalDate.now(); // when final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_debitor#1000111:FirstGmbH-firstcontact.admin"); - givenMembership.setValidity(Range.closedOpen( - givenMembership.getValidity().lower(), newValidityEnd)); + // TODO: we should test with debitor- and partner-admin as well + context("superuser-alex@hostsharing.net", "hs_office_membership#M-1000113:AGENT"); + givenMembership.setValidity( + Range.closedOpen(givenMembership.getValidity().lower(), newValidityEnd)); return membershipRepo.save(givenMembership); }); @@ -279,7 +253,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { .extracting(Object::toString).isEqualTo(saved.toString()); } - private void assertThatMembershipIsVisibleForUserWithRole( + private void assertThatMembershipIsVisibleForRole( final HsOfficeMembershipEntity entity, final String assumedRoles) { jpaAttempt.transacted(() -> { @@ -287,16 +261,6 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { assertThatMembershipExistsAndIsAccessibleToCurrentContext(entity); }).assertSuccessful(); } - - private void assertThatMembershipIsNotVisibleForUserWithRole( - final HsOfficeMembershipEntity entity, - final String assumedRoles) { - jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", assumedRoles); - final var found = membershipRepo.findByUuid(entity.getUuid()); - assertThat(found).isEmpty(); - }).assertSuccessful(); - } } @Nested @@ -306,7 +270,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { public void globalAdmin_withoutAssumedRole_canDeleteAnyMembership() { // given context("superuser-alex@hostsharing.net", null); - final var givenMembership = givenSomeTemporaryMembership("First", "Second"); + final var givenMembership = givenSomeTemporaryMembership("First", "12"); // when final var result = jpaAttempt.transacted(() -> { @@ -323,14 +287,14 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { } @Test - public void nonGlobalAdmin_canNotDeleteTheirRelatedMembership() { + public void partnerRelationAgent_canNotDeleteTheirRelatedMembership() { // given context("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembership("First", "Third"); + final var givenMembership = givenSomeTemporaryMembership("First", "14"); // when final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_debitor#1000313:ThirdOHG-thirdcontact.admin"); + context("superuser-alex@hostsharing.net", "hs_office_relation#HostsharingeG-with-PARTNER-FirstGmbH:AGENT"); assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent(); membershipRepo.deleteByUuid(givenMembership.getUuid()); @@ -350,13 +314,9 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { public void deletingAMembershipAlsoDeletesRelatedRolesAndGrants() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); - final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); - final var givenMembership = givenSomeTemporaryMembership("First", "First"); - assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created") - .isEqualTo(initialRoleNames.length + 5); - assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created") - .isEqualTo(initialGrantNames.length + 18); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenMembership = givenSomeTemporaryMembership("First", "15"); // when final var result = jpaAttempt.transacted(() -> { @@ -367,8 +327,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); } } @@ -376,9 +336,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'membernumbersuffix' + from tx_journal_v where targettable = 'hs_office_membership'; """); @@ -387,50 +346,26 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating Membership test-data FirstGmbH11, hs_office_membership, INSERT]", - "[creating Membership test-data Seconde.K.12, hs_office_membership, INSERT]"); + "[creating Membership test-data, hs_office_membership, INSERT, 01]", + "[creating Membership test-data, hs_office_membership, INSERT, 02]", + "[creating Membership test-data, hs_office_membership, INSERT, 03]"); } - @BeforeEach - @AfterEach - void cleanup() { - tempEntities.forEach(tempMembership -> { - jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", null); - System.out.println("DELETING temporary membership: " + tempMembership.toString()); - membershipRepo.deleteByUuid(tempMembership.getUuid()); - }); - }); - jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", null); - em.createQuery("DELETE FROM HsOfficeMembershipEntity WHERE memberNumberSuffix >= '20'"); - }); - } - - private HsOfficeMembershipEntity givenSomeTemporaryMembership(final String partnerTradeName, final String debitorName) { + private HsOfficeMembershipEntity givenSomeTemporaryMembership(final String partnerTradeName, final String memberNumberSuffix) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partnerTradeName).get(0); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).get(0); final var newMembership = HsOfficeMembershipEntity.builder() - .memberNumberSuffix("02") + .memberNumberSuffix(memberNumberSuffix) .partner(givenPartner) - .mainDebitor(givenDebitor) .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) .membershipFeeBillable(true) .build(); - toCleanup(newMembership); - - return membershipRepo.save(newMembership); + return toCleanup(membershipRepo.save(newMembership).load()); }).assertSuccessful().returnedValue(); } - private HsOfficeMembershipEntity toCleanup(final HsOfficeMembershipEntity tempEntity) { - tempEntities.add(tempEntity); - return tempEntity; - } - void exactlyTheseMembershipsAreReturned( final List actualResult, final String... membershipNames) { @@ -438,10 +373,4 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { .extracting(membershipEntity -> membershipEntity.toString()) .containsExactlyInAnyOrder(membershipNames); } - - void allTheseMembershipsAreReturned(final List actualResult, final String... membershipNames) { - assertThat(actualResult) - .extracting(membershipEntity -> membershipEntity.toString()) - .contains(membershipNames); - } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/TestHsMembership.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/TestHsMembership.java index ff50eb58..857e9369 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/TestHsMembership.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/TestHsMembership.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.hs.office.membership; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import java.time.LocalDate; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java deleted file mode 100644 index 30c153ec..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java +++ /dev/null @@ -1,1064 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.migration; - -import com.opencsv.CSVParserBuilder; -import com.opencsv.CSVReader; -import com.opencsv.CSVReaderBuilder; -import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; -import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity; -import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType; -import net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionEntity; -import net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionType; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; -import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; -import net.hostsharing.hsadminng.hs.office.membership.HsOfficeReasonForTermination; -import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerDetailsEntity; -import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; -import net.hostsharing.hsadminng.hs.office.relationship.HsOfficeRelationshipEntity; -import net.hostsharing.hsadminng.hs.office.relationship.HsOfficeRelationshipType; -import net.hostsharing.hsadminng.hs.office.sepamandate.HsOfficeSepaMandateEntity; -import net.hostsharing.test.JpaAttempt; -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.TestWatcher; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.Commit; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.transaction.support.TransactionTemplate; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.NotNull; -import java.io.*; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; - -import static java.util.Arrays.stream; -import static java.util.Objects.requireNonNull; -import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; -import static org.apache.commons.lang3.StringUtils.isBlank; -import static org.apache.commons.lang3.StringUtils.isNotBlank; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assumptions.assumeThat; -import static org.assertj.core.api.Fail.fail; - -/* - * This 'test' includes the complete legacy 'office' data import. - * - * There is no code in 'main' because the import is not needed a normal runtime. - * There is some test data in Java resources to verify the data conversion. - * For a real import a main method will be added later - * which reads CSV files from the file system. - * - * When run on a Hostsharing database, it needs the following settings (hsh99_... just examples). - * - * In a real Hostsharing environment, these are created via (the old) hsadmin: - - CREATE USER hsh99_admin WITH PASSWORD 'password'; - CREATE DATABASE hsh99_hsadminng ENCODING 'UTF8' TEMPLATE template0; - REVOKE ALL ON DATABASE hsh99_hsadminng FROM public; -- why does hsadmin do that? - ALTER DATABASE hsh99_hsadminng OWNER TO hsh99_admin; - - CREATE USER hsh99_restricted WITH PASSWORD 'password'; - - \c hsh99_hsadminng - - GRANT ALL PRIVILEGES ON SCHEMA public to hsh99_admin; - - * Additionally, we need these settings (because the Hostsharing DB-Admin has no CREATE right): - - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - - -- maybe something like that is needed for the 2nd user - -- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public to hsh99_restricted; - - * Then copy this to a file named .environment (excluded from git) and fill in your specific values: - - export HSADMINNG_POSTGRES_JDBC_URL=jdbc:postgresql://localhost:6432/hsh99_hsadminng - export HSADMINNG_POSTGRES_ADMIN_USERNAME=hsh99_admin - export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password - export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=hsh99_restricted - export HSADMINNG_SUPERUSER=some-precreated-superuser@example.org - - * To finally import the office data, run: - * - * import-office-tables # comes from .aliases file and uses .environment - */ -@Tag("import") -@DataJpaTest(properties = { - "spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers}", - "spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:admin}", - "spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}", - "hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}" -}) -@DirtiesContext -@Import({ Context.class, JpaAttempt.class }) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -@ExtendWith(OrderedDependedTestsExtension.class) -public class ImportOfficeData extends ContextBasedTest { - - private static final String[] SUBSCRIBER_ROLES = new String[] { - "subscriber:operations-discussion", - "subscriber:operations-announce", - "subscriber:members-announce", - "subscriber:members-discussion", - "subscriber:customers-announce" - }; - private static final String[] KNOWN_ROLES = ArrayUtils.addAll( - new String[]{"partner", "vip-contact", "ex-partner", "billing", "contractual", "operation"}, - SUBSCRIBER_ROLES); - - static int relationshipId = 2000000; - - @Value("${spring.datasource.url}") - private String jdbcUrl; - - @Value("${spring.datasource.username}") - private String postgresAdminUser; - - @Value("${hsadminng.superuser}") - private String rbacSuperuser; - - private static NavigableMap contacts = new TreeMap<>(); - private static NavigableMap persons = new TreeMap<>(); - private static NavigableMap partners = new TreeMap<>(); - private static NavigableMap debitors = new TreeMap<>(); - private static NavigableMap memberships = new TreeMap<>(); - - private static NavigableMap relationships = new TreeMap<>(); - private static NavigableMap sepaMandates = new TreeMap<>(); - private static NavigableMap bankAccounts = new TreeMap<>(); - private static NavigableMap coopShares = new TreeMap<>(); - private static NavigableMap coopAssets = new TreeMap<>(); - - @PersistenceContext - EntityManager em; - - @Autowired - TransactionTemplate txTemplate; - - @Autowired - JpaAttempt jpaAttempt; - - @MockBean - HttpServletRequest request; - - @Test - @Order(1010) - void importBusinessPartners() { - - try (Reader reader = resourceReader("migration/business-partners.csv")) { - final var lines = readAllLines(reader); - importBusinessPartners(justHeader(lines), withoutHeader(lines)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - @Order(1011) - void verifyBusinessPartners() { - assumeThat(postgresAdminUser).isEqualTo("admin"); - - // no contacts yet => mostly null values - assertThat(toFormattedString(partners)).isEqualToIgnoringWhitespace(""" - { - 17=partner(null null, null), - 20=partner(null null, null), - 22=partner(null null, null) - } - """); - assertThat(toFormattedString(contacts)).isEqualTo("{}"); - assertThat(toFormattedString(debitors)).isEqualToIgnoringWhitespace(""" - { - 17=debitor(D-1001700: null null, null: mih), - 20=debitor(D-1002000: null null, null: xyz), - 22=debitor(D-1102200: null null, null: xxx)} - """); - assertThat(toFormattedString(memberships)).isEqualToIgnoringWhitespace(""" - { - 17=Membership(M-1001700, null null, null, D-1001700, [2000-12-06,), NONE), - 20=Membership(M-1002000, null null, null, D-1002000, [2000-12-06,2016-01-01), UNKNOWN), - 22=Membership(M-1102200, null null, null, D-1102200, [2021-04-01,), NONE) - } - """); - } - - @Test - @Order(1020) - void importContacts() { - - try (Reader reader = resourceReader("migration/contacts.csv")) { - final var lines = readAllLines(reader); - importContacts(justHeader(lines), withoutHeader(lines)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - @Order(1021) - void verifyContacts() { - assumeThat(postgresAdminUser).isEqualTo("admin"); - - assertThat(toFormattedString(partners)).isEqualToIgnoringWhitespace(""" - { - 17=partner(NP Mellies, Michael: Herr Michael Mellies ), - 20=partner(LP JM GmbH: Herr Philip Meyer-Contract , JM GmbH), - 22=partner(?? Test PS: Petra Schmidt , Test PS) - } - """); - assertThat(toFormattedString(contacts)).isEqualToIgnoringWhitespace(""" - { - 1101=contact(label='Herr Michael Mellies ', emailAddresses='mih@example.org'), - 1200=contact(label='JM e.K.', emailAddresses='jm-ex-partner@example.org'), - 1201=contact(label='Frau Dr. Jenny Meyer-Billing , JM GmbH', emailAddresses='jm-billing@example.org'), - 1202=contact(label='Herr Andrew Meyer-Operation , JM GmbH', emailAddresses='am-operation@example.org'), - 1203=contact(label='Herr Philip Meyer-Contract , JM GmbH', emailAddresses='pm-partner@example.org'), - 1301=contact(label='Petra Schmidt , Test PS', emailAddresses='ps@example.com') - } - """); - assertThat(toFormattedString(persons)).isEqualToIgnoringWhitespace(""" - { - 1101=person(personType='NP', tradeName='', familyName='Mellies', givenName='Michael'), - 1200=person(personType='LP', tradeName='JM e.K.', familyName='', givenName=''), - 1201=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Billing', givenName='Jenny'), - 1202=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Operation', givenName='Andrew'), - 1203=person(personType='LP', tradeName='JM GmbH', familyName='Meyer-Contract', givenName='Philip'), - 1301=person(personType='??', tradeName='Test PS', familyName='Schmidt', givenName='Petra') - } - """); - assertThat(toFormattedString(debitors)).isEqualToIgnoringWhitespace(""" - { - 17=debitor(D-1001700: NP Mellies, Michael: mih), - 20=debitor(D-1002000: LP JM GmbH: xyz), - 22=debitor(D-1102200: ?? Test PS: xxx) - } - """); - assertThat(toFormattedString(memberships)).isEqualToIgnoringWhitespace(""" - { - 17=Membership(M-1001700, NP Mellies, Michael, D-1001700, [2000-12-06,), NONE), - 20=Membership(M-1002000, LP JM GmbH, D-1002000, [2000-12-06,2016-01-01), UNKNOWN), - 22=Membership(M-1102200, ?? Test PS, D-1102200, [2021-04-01,), NONE) - } - """); - assertThat(toFormattedString(relationships)).isEqualToIgnoringWhitespace(""" - { - 2000000=rel(relAnchor='NP Mellies, Michael', relType='OPERATIONS', relHolder='NP Mellies, Michael', contact='Herr Michael Mellies '), - 2000001=rel(relAnchor='LP JM GmbH', relType='EX_PARTNER', relHolder='LP JM e.K.', contact='JM e.K.'), - 2000002=rel(relAnchor='LP JM GmbH', relType='OPERATIONS', relHolder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), - 2000003=rel(relAnchor='LP JM GmbH', relType='VIP_CONTACT', relHolder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), - 2000004=rel(relAnchor='LP JM GmbH', relType='SUBSCRIBER', relMark='operations-announce', relHolder='LP JM GmbH', contact='Herr Andrew Meyer-Operation , JM GmbH'), - 2000005=rel(relAnchor='LP JM GmbH', relType='REPRESENTATIVE', relHolder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), - 2000006=rel(relAnchor='LP JM GmbH', relType='SUBSCRIBER', relMark='members-announce', relHolder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), - 2000007=rel(relAnchor='LP JM GmbH', relType='SUBSCRIBER', relMark='customers-announce', relHolder='LP JM GmbH', contact='Herr Philip Meyer-Contract , JM GmbH'), - 2000008=rel(relAnchor='?? Test PS', relType='OPERATIONS', relHolder='?? Test PS', contact='Petra Schmidt , Test PS'), - 2000009=rel(relAnchor='?? Test PS', relType='REPRESENTATIVE', relHolder='?? Test PS', contact='Petra Schmidt , Test PS'), - 2000010=rel(relAnchor='NP Mellies, Michael', relType='REPRESENTATIVE', relHolder='NP Mellies, Michael', contact='Herr Michael Mellies ') - } - """); - } - - @Test - @Order(1030) - void importSepaMandates() { - - try (Reader reader = resourceReader("migration/sepa-mandates.csv")) { - final var lines = readAllLines(reader); - importSepaMandates(justHeader(lines), withoutHeader(lines)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - @Order(1031) - void verifySepaMandates() { - assumeThat(postgresAdminUser).isEqualTo("admin"); - - assertThat(toFormattedString(bankAccounts)).isEqualToIgnoringWhitespace(""" - { - 234234=bankAccount(holder='Michael Mellies', iban='DE37500105177419788228', bic='INGDDEFFXXX'), - 235600=bankAccount(holder='JM e.K.', iban='DE02300209000106531065', bic='CMCIDEDD'), - 235662=bankAccount(holder='JM GmbH', iban='DE49500105174516484892', bic='INGDDEFFXXX') - } - """); - assertThat(toFormattedString(sepaMandates)).isEqualToIgnoringWhitespace(""" - { - 234234=SEPA-Mandate(DE37500105177419788228, MH12345, 2004-06-12, [2004-06-15,)), - 235600=SEPA-Mandate(DE02300209000106531065, JM33344, 2004-01-15, [2004-01-20,2005-06-28)), - 235662=SEPA-Mandate(DE49500105174516484892, JM33344, 2005-06-28, [2005-07-01,)) - } - """); - } - - @Test - @Order(1040) - void importCoopShares() { - try (Reader reader = resourceReader("migration/share-transactions.csv")) { - final var lines = readAllLines(reader); - importCoopShares(justHeader(lines), withoutHeader(lines)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - @Order(1041) - void verifyCoopShares() { - assumeThat(postgresAdminUser).isEqualTo("admin"); - - assertThat(toFormattedString(coopShares)).isEqualToIgnoringWhitespace(""" - { - 33443=CoopShareTransaction(1001700, 2000-12-06, SUBSCRIPTION, 20, initial share subscription), - 33451=CoopShareTransaction(1002000, 2000-12-06, SUBSCRIPTION, 2, initial share subscription), - 33701=CoopShareTransaction(1001700, 2005-01-10, SUBSCRIPTION, 40, increase), - 33810=CoopShareTransaction(1002000, 2016-12-31, CANCELLATION, 22, membership ended) - } - """); - } - - @Test - @Order(1050) - void importCoopAssets() { - - try (Reader reader = resourceReader("migration/asset-transactions.csv")) { - final var lines = readAllLines(reader); - importCoopAssets(justHeader(lines), withoutHeader(lines)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - @Order(1051) - void verifyCoopAssets() { - assumeThat(postgresAdminUser).isEqualTo("admin"); - - assertThat(toFormattedString(coopAssets)).isEqualToIgnoringWhitespace(""" - { - 30000=CoopAssetsTransaction(1001700, 2000-12-06, DEPOSIT, 1280.00, for subscription A), - 31000=CoopAssetsTransaction(1002000, 2000-12-06, DEPOSIT, 128.00, for subscription B), - 32000=CoopAssetsTransaction(1001700, 2005-01-10, DEPOSIT, 2560.00, for subscription C), - 33001=CoopAssetsTransaction(1001700, 2005-01-10, TRANSFER, -512.00, for transfer to 10), - 33002=CoopAssetsTransaction(1002000, 2005-01-10, ADOPTION, 512.00, for transfer from 7), - 34001=CoopAssetsTransaction(1002000, 2016-12-31, CLEARING, -8.00, for cancellation D), - 34002=CoopAssetsTransaction(1002000, 2016-12-31, DISBURSAL, -100.00, for cancellation D), - 34003=CoopAssetsTransaction(1002000, 2016-12-31, LOSS, -20.00, for cancellation D) - } - """); - } - - @Test - @Order(2000) - @Commit - void persistEntities() { - - System.out.println("PERSISTING to database '" + jdbcUrl + "' as user '" + postgresAdminUser + "'"); - deleteTestDataFromHsOfficeTables(); - resetFromHsOfficeSequences(); - deleteFromTestTables(); - deleteFromRbacTables(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - contacts.forEach(this::persist); - updateLegacyIds(contacts, "hs_office_contact_legacy_id", "contact_id"); - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - persons.forEach(this::persist); - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - partners.forEach(this::persist); - updateLegacyIds(partners, "hs_office_partner_legacy_id", "bp_id"); - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - debitors.forEach(this::persist); - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - memberships.forEach(this::persist); - - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - relationships.forEach(this::persist); - - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - bankAccounts.forEach(this::persist); - - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - sepaMandates.forEach(this::persist); - updateLegacyIds(sepaMandates, "hs_office_sepamandate_legacy_id", "sepa_mandate_id"); - - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - coopShares.forEach(this::persist); - updateLegacyIds(coopShares, "hs_office_coopsharestransaction_legacy_id", "member_share_id"); - - }).assertSuccessful(); - - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - coopAssets.forEach(this::persist); - updateLegacyIds(coopShares, "hs_office_coopassetstransaction_legacy_id", "member_asset_id"); - }).assertSuccessful(); - - } - - private void persist(final Integer id, final HasUuid entity) { - try { - System.out.println("persisting #" + entity.hashCode() + ": " + entity.toString()); - em.persist(entity); - em.flush(); - System.out.println("persisted #" + entity.hashCode() + " as " + entity.getUuid()); - } catch (Exception x) { - System.out.println("failed to persist: " + entity.toString()); - throw x; - } - - } - - private void deleteTestDataFromHsOfficeTables() { - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - em.createNativeQuery("delete from hs_office_relationship where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_coopassetstransaction where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_coopassetstransaction_legacy_id where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_coopsharestransaction where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_coopsharestransaction_legacy_id where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_membership where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_sepamandate where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_sepamandate_legacy_id where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_debitor where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_bankaccount where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_partner where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_partner_details where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_contact where true").executeUpdate(); - em.createNativeQuery("delete from hs_office_person where true").executeUpdate(); - }).assertSuccessful(); - } - - private void resetFromHsOfficeSequences() { - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - em.createNativeQuery("alter sequence hs_office_contact_legacy_id_seq restart with 1000000000;").executeUpdate(); - em.createNativeQuery("alter sequence hs_office_coopassetstransaction_legacy_id_seq restart with 1000000000;") - .executeUpdate(); - em.createNativeQuery("alter sequence public.hs_office_coopsharestransaction_legacy_id_seq restart with 1000000000;") - .executeUpdate(); - em.createNativeQuery("alter sequence public.hs_office_partner_legacy_id_seq restart with 1000000000;") - .executeUpdate(); - em.createNativeQuery("alter sequence public.hs_office_sepamandate_legacy_id_seq restart with 1000000000;") - .executeUpdate(); - }); - } - - private void deleteFromTestTables() { - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - em.createNativeQuery("delete from test_domain where true").executeUpdate(); - em.createNativeQuery("delete from test_package where true").executeUpdate(); - em.createNativeQuery("delete from test_customer where true").executeUpdate(); - }).assertSuccessful(); - } - - private void deleteFromRbacTables() { - jpaAttempt.transacted(() -> { - context(rbacSuperuser); - em.createNativeQuery("delete from rbacuser_rv where name not like 'superuser-%'").executeUpdate(); - em.createNativeQuery("delete from tx_journal where true").executeUpdate(); - em.createNativeQuery("delete from tx_context where true").executeUpdate(); - }).assertSuccessful(); - } - - private void updateLegacyIds( - Map entities, - final String legacyIdTable, - final String legacyIdColumn) { - entities.forEach((id, entity) -> em.createNativeQuery(""" - UPDATE ${legacyIdTable} - SET ${legacyIdColumn} = :legacyId - WHERE uuid = :uuid - """ - .replace("${legacyIdTable}", legacyIdTable) - .replace("${legacyIdColumn}", legacyIdColumn)) - .setParameter("legacyId", id) - .setParameter("uuid", entity.getUuid()) - .executeUpdate() - ); - } - - public List readAllLines(Reader reader) throws Exception { - - final var parser = new CSVParserBuilder() - .withSeparator(';') - .withQuoteChar('"') - .build(); - - final var filteredReader = skippingEmptyAndCommentLines(reader); - try (CSVReader csvReader = new CSVReaderBuilder(filteredReader) - .withCSVParser(parser) - .build()) { - return csvReader.readAll(); - } - } - - public static Reader skippingEmptyAndCommentLines(Reader reader) throws IOException { - try (var bufferedReader = new BufferedReader(reader); - StringWriter writer = new StringWriter()) { - - String line; - while ((line = bufferedReader.readLine()) != null) { - if (!line.isBlank() && !line.startsWith("#")) { - writer.write(line); - writer.write("\n"); - } - } - - return new StringReader(writer.toString()); - } - } - - private void importBusinessPartners(final String[] header, final List records) { - - final var columns = new Columns(header); - - records.stream() - .map(this::trimAll) - .map(row -> new Record(columns, row)) - .forEach(rec -> { - final var person = HsOfficePersonEntity.builder().build(); - - final var partner = HsOfficePartnerEntity.builder() - .partnerNumber(rec.getInteger("member_id")) - .details(HsOfficePartnerDetailsEntity.builder().build()) - .contact(null) // is set during contacts import depending on assigned roles - .person(person) - .build(); - partners.put(rec.getInteger("bp_id"), partner); - - final var debitor = HsOfficeDebitorEntity.builder() - .partner(partner) - .debitorNumberSuffix((byte) 0) - .defaultPrefix(rec.getString("member_code").replace("hsh00-", "")) - .partner(partner) - .billable(rec.isEmpty("free")) - .vatReverseCharge(rec.getBoolean("exempt_vat")) - .vatBusiness("GROSS".equals(rec.getString("indicator_vat"))) // TODO: remove - .vatId(rec.getString("uid_vat")) - .build(); - debitors.put(rec.getInteger("bp_id"), debitor); - - partners.put(rec.getInteger("bp_id"), partner); - - if (isNotBlank(rec.getString("member_since"))) { - assertThat(rec.getInteger("member_id")).isEqualTo(partner.getPartnerNumber()); - final var membership = HsOfficeMembershipEntity.builder() - .partner(partner) - .memberNumberSuffix("00") - .validity(toPostgresDateRange( - rec.getLocalDate("member_since"), - rec.getLocalDate("member_until"))) - .membershipFeeBillable(rec.isEmpty("member_role")) - .reasonForTermination( - isBlank(rec.getString("member_until")) - ? HsOfficeReasonForTermination.NONE - : HsOfficeReasonForTermination.UNKNOWN) - .mainDebitor(debitor) - .build(); - memberships.put(rec.getInteger("bp_id"), membership); - } - }); - } - - private void importCoopShares(final String[] header, final List records) { - - final var columns = new Columns(header); - - records.stream() - .map(this::trimAll) - .map(row -> new Record(columns, row)) - .forEach(rec -> { - final var member = memberships.get(rec.getInteger("bp_id")); - - final var shareTransaction = HsOfficeCoopSharesTransactionEntity.builder() - .membership(member) - .valueDate(rec.getLocalDate("date")) - .transactionType( - "SUBSCRIPTION".equals(rec.getString("action")) - ? HsOfficeCoopSharesTransactionType.SUBSCRIPTION - : "UNSUBSCRIPTION".equals(rec.getString("action")) - ? HsOfficeCoopSharesTransactionType.CANCELLATION - : HsOfficeCoopSharesTransactionType.ADJUSTMENT - ) - .shareCount(rec.getInteger("quantity")) - .comment( rec.getString("comment")) - .build(); - - coopShares.put(rec.getInteger("member_share_id"), shareTransaction); - }); - } - - private void importCoopAssets(final String[] header, final List records) { - - final var columns = new Columns(header); - - records.stream() - .map(this::trimAll) - .map(row -> new Record(columns, row)) - .forEach(rec -> { - final var member = memberships.get(rec.getInteger("bp_id")); - - final var assetTypeMapping = new HashMap() { - - { - put("HANDOVER", HsOfficeCoopAssetsTransactionType.TRANSFER); - put("ADOPTION", HsOfficeCoopAssetsTransactionType.ADOPTION); - put("LOSS", HsOfficeCoopAssetsTransactionType.LOSS); - put("CLEARING", HsOfficeCoopAssetsTransactionType.CLEARING); - put("PRESCRIPTION", HsOfficeCoopAssetsTransactionType.LIMITATION); - put("PAYBACK", HsOfficeCoopAssetsTransactionType.DISBURSAL); - put("PAYMENT", HsOfficeCoopAssetsTransactionType.DEPOSIT); - } - - public HsOfficeCoopAssetsTransactionType get(final String key) { - final var value = super.get(key); - if (value != null) { - return value; - } - throw new IllegalStateException("no mapping value found for: " + key); - } - }; - - final var assetTransaction = HsOfficeCoopAssetsTransactionEntity.builder() - .membership(member) - .valueDate(rec.getLocalDate("date")) - .transactionType(assetTypeMapping.get(rec.getString("action"))) - .assetValue(rec.getBigDecimal("amount")) - .comment(rec.getString("comment")) - .build(); - - coopAssets.put(rec.getInteger("member_asset_id"), assetTransaction); - }); - } - - private void importSepaMandates(final String[] header, final List records) { - - final var columns = new Columns(header); - - records.stream() - .map(this::trimAll) - .map(row -> new Record(columns, row)) - .forEach(rec -> { - final var debitor = debitors.get(rec.getInteger("bp_id")); - - final var sepaMandate = HsOfficeSepaMandateEntity.builder() - .debitor(debitor) - .bankAccount(HsOfficeBankAccountEntity.builder() - .holder(rec.getString("bank_customer")) - // .bankName(rec.get("bank_name")) // not supported - .iban(rec.getString("bank_iban")) - .bic(rec.getString("bank_bic")) - .build()) - .reference(rec.getString("mandat_ref")) - .agreement(LocalDate.parse(rec.getString("mandat_signed"))) - .validity(toPostgresDateRange( - rec.getLocalDate("mandat_since"), - rec.getLocalDate("mandat_until"))) - .build(); - - sepaMandates.put(rec.getInteger("sepa_mandat_id"), sepaMandate); - bankAccounts.put(rec.getInteger("sepa_mandat_id"), sepaMandate.getBankAccount()); - }); - } - - private void importContacts(final String[] header, final List records) { - - final var columns = new Columns(header); - - records.stream() - .map(this::trimAll) - .map(row -> new Record(columns, row)) - .forEach(rec -> { - final var contactId = rec.getInteger("contact_id"); - - if (rec.getString("roles").isBlank()) { - fail("empty roles assignment not allowed for contact_id: " + contactId); - } - - final var partner = partners.get(rec.getInteger("bp_id")); - final var debitor = debitors.get(rec.getInteger("bp_id")); - - final var partnerPerson = partner.getPerson(); - if (containsRole(rec)) { - initPerson(partner.getPerson(), rec); - } - - HsOfficePersonEntity contactPerson = partnerPerson; - if (!StringUtils.equals(rec.getString("firma"), partnerPerson.getTradeName()) || - !StringUtils.equals(rec.getString("first_name"), partnerPerson.getGivenName()) || - !StringUtils.equals(rec.getString("last_name"), partnerPerson.getFamilyName())) { - contactPerson = initPerson(HsOfficePersonEntity.builder().build(), rec); - } - - final var contact = HsOfficeContactEntity.builder().build(); - initContact(contact, rec); - - if (containsRole(rec, "partner")) { - assertThat(partner.getContact()).isNull(); - partner.setContact(contact); - } - if (containsRole(rec, "billing")) { - assertThat(debitor.getBillingContact()).isNull(); - debitor.setBillingContact(contact); - } - if (containsRole(rec, "operation")) { - addRelationship(partnerPerson, contactPerson, contact, HsOfficeRelationshipType.OPERATIONS); - } - if (containsRole(rec, "contractual")) { - addRelationship(partnerPerson, contactPerson, contact, HsOfficeRelationshipType.REPRESENTATIVE); - } - if (containsRole(rec, "ex-partner")) { - addRelationship(partnerPerson, contactPerson, contact, HsOfficeRelationshipType.EX_PARTNER); - } - if (containsRole(rec, "vip-contact")) { - addRelationship(partnerPerson, contactPerson, contact, HsOfficeRelationshipType.VIP_CONTACT); - } - for (String subscriberRole: SUBSCRIBER_ROLES) { - if (containsRole(rec, subscriberRole)) { - addRelationship(partnerPerson, contactPerson, contact, HsOfficeRelationshipType.SUBSCRIBER) - .setRelMark(subscriberRole.split(":")[1]) - ; - } - } - verifyContainsOnlyKnownRoles(rec.getString("roles")); - }); - - optionallyAddMissingContractualRelationships(); - } - - private static void optionallyAddMissingContractualRelationships() { - partners.forEach( (id, partner) -> { - final var partnerPerson = partner.getPerson(); - if (relationships.values().stream().filter(rel -> rel.getRelHolder() == partnerPerson && rel.getRelType() == HsOfficeRelationshipType.REPRESENTATIVE).findFirst().isEmpty()) { - addRelationship(partnerPerson, partnerPerson, partner.getContact(), HsOfficeRelationshipType.REPRESENTATIVE); - } - }); - } - - private static boolean containsRole(final Record rec, final String role) { - final var roles = rec.getString("roles"); - return ("," + roles + ",").contains("," + role + ","); - } - - private static boolean containsRole(final Record rec) { - return containsRole(rec, "partner"); - } - - private static HsOfficeRelationshipEntity addRelationship( - final HsOfficePersonEntity partnerPerson, - final HsOfficePersonEntity contactPerson, - final HsOfficeContactEntity contact, - final HsOfficeRelationshipType representative) { - final var rel = HsOfficeRelationshipEntity.builder() - .relAnchor(partnerPerson) - .relHolder(contactPerson) - .contact(contact) - .relType(representative) - .build(); - relationships.put(relationshipId++, rel); - return rel; - } - - private HsOfficePersonEntity initPerson(final HsOfficePersonEntity person, final Record contactRecord) { - // TODO: title+salutation: add to person - person.setGivenName(contactRecord.getString("first_name")); - person.setFamilyName(contactRecord.getString("last_name")); - person.setTradeName(contactRecord.getString("firma")); - determinePersonType(person, contactRecord.getString("roles")); - - persons.put(contactRecord.getInteger("contact_id"), person); - return person; - } - - private static void determinePersonType(final HsOfficePersonEntity person, final String roles) { - if (person.getTradeName().isBlank()) { - person.setPersonType(HsOfficePersonType.NATURAL_PERSON); - } else - // contractual && !partner with a firm and a natural person name - // should actually be split up into two persons - // but the legacy database consists such records - if (roles.contains("contractual") && !roles.contains("partner") && - !person.getFamilyName().isBlank() && !person.getGivenName().isBlank()) { - person.setPersonType(HsOfficePersonType.NATURAL_PERSON); - } else if ( endsWithWord(person.getTradeName(), "e.K.", "e.G.", "eG", "GmbH", "AG") ) { - person.setPersonType(HsOfficePersonType.LEGAL_PERSON); - } else if ( endsWithWord(person.getTradeName(), "OHG") ) { - person.setPersonType(HsOfficePersonType.INCORPORATED_FIRM); - } else if ( endsWithWord(person.getTradeName(), "GbR") ) { - person.setPersonType(HsOfficePersonType.INCORPORATED_FIRM); - } else { - person.setPersonType(HsOfficePersonType.UNKNOWN_PERSON_TYPE); - } - } - - private static boolean endsWithWord(final String value, final String... endings) { - final var lowerCaseValue = value.toLowerCase(); - for( String ending: endings ) { - if (lowerCaseValue.endsWith(" " + ending.toLowerCase())) { - return true; - } - } - return false; - } - - private void verifyContainsOnlyKnownRoles(final String roles) { - final var allowedRolesSet = stream(KNOWN_ROLES).collect(Collectors.toSet()); - final var givenRolesSet = stream(roles.replace(" ", "").split(",")).collect(Collectors.toSet()); - final var unexpectedRolesSet = new HashSet<>(givenRolesSet); - unexpectedRolesSet.removeAll(allowedRolesSet); - assertThat(unexpectedRolesSet).isEmpty(); - } - - private HsOfficeContactEntity initContact(final HsOfficeContactEntity contact, final Record contactRecord) { - - contact.setLabel(toLabel( - contactRecord.getString("salut"), - contactRecord.getString("title"), - contactRecord.getString("first_name"), - contactRecord.getString("last_name"), - contactRecord.getString("firma"))); - contact.setEmailAddresses(contactRecord.getString("email")); - contact.setPostalAddress(toAddress(contactRecord)); - contact.setPhoneNumbers(toPhoneNumbers(contactRecord)); - - contacts.put(contactRecord.getInteger("contact_id"), contact); - return contact; - } - - private String toFormattedString(final Map map) { - if ( map.isEmpty() ) { - return "{}"; - } - return "{\n" + - map.keySet().stream() - .map(id -> " " + id + "=" + map.get(id).toString()) - .collect(Collectors.joining(",\n")) + - "\n}\n"; - } - - private String[] trimAll(final String[] record) { - for (int i = 0; i < record.length; ++i) { - if (record[i] != null) { - record[i] = record[i].trim(); - } - } - return record; - } - - private String toPhoneNumbers(final Record rec) { - final var result = new StringBuilder("{\n"); - if (isNotBlank(rec.getString("phone_private"))) - result.append(" \"private\": " + "\"" + rec.getString("phone_private") + "\",\n"); - if (isNotBlank(rec.getString("phone_office"))) - result.append(" \"office\": " + "\"" + rec.getString("phone_office") + "\",\n"); - if (isNotBlank(rec.getString("phone_mobile"))) - result.append(" \"mobile\": " + "\"" + rec.getString("phone_mobile") + "\",\n"); - if (isNotBlank(rec.getString("fax"))) - result.append(" \"fax\": " + "\"" + rec.getString("fax") + "\",\n"); - return (result + "}").replace("\",\n}", "\"\n}"); - } - - private String toAddress(final Record rec) { - final var result = new StringBuilder(); - final var name = toName( - rec.getString("salut"), - rec.getString("title"), - rec.getString("first_name"), - rec.getString("last_name")); - if (isNotBlank(name)) - result.append(name + "\n"); - if (isNotBlank(rec.getString("firma"))) - result.append(rec.getString("firma") + "\n"); - if (isNotBlank(rec.getString("co"))) - result.append("c/o " + rec.getString("co") + "\n"); - if (isNotBlank(rec.getString("street"))) - result.append(rec.getString("street") + "\n"); - final var zipcodeAndCity = toZipcodeAndCity(rec); - if (isNotBlank(zipcodeAndCity)) - result.append(zipcodeAndCity + "\n"); - return result.toString(); - } - - private String toZipcodeAndCity(final Record rec) { - final var result = new StringBuilder(); - if (isNotBlank(rec.getString("country"))) - result.append(rec.getString("country") + " "); - if (isNotBlank(rec.getString("zipcode"))) - result.append(rec.getString("zipcode") + " "); - if (isNotBlank(rec.getString("city"))) - result.append(rec.getString("city")); - return result.toString(); - } - - private String toLabel( - final String salut, - final String title, - final String firstname, - final String lastname, - final String firm) { - final var result = new StringBuilder(); - if (isNotBlank(salut)) - result.append(salut + " "); - if (isNotBlank(title)) - result.append(title + " "); - if (isNotBlank(firstname)) - result.append(firstname + " "); - if (isNotBlank(lastname)) - result.append(lastname + " "); - if (isNotBlank(firm)) { - result.append( (isBlank(result) ? "" : ", ") + firm); - } - return result.toString(); - } - - private String toName(final String salut, final String title, final String firstname, final String lastname) { - return toLabel(salut, title, firstname, lastname, null); - } - - private Reader resourceReader(@NotNull final String resourcePath) { - return new InputStreamReader(requireNonNull(getClass().getClassLoader().getResourceAsStream(resourcePath))); - } - - private Reader fileReader(@NotNull final Path filePath) throws IOException { - // Path path = Paths.get( - // ClassLoader.getSystemResource("csv/twoColumn.csv").toURI()) - // ); - return Files.newBufferedReader(filePath); - } - - private static String[] justHeader(final List lines) { - return stream(lines.getFirst()).map(String::trim).toArray(String[]::new); - } - - private List withoutHeader(final List records) { - return records.subList(1, records.size()); - } - -} - -class Columns { - - private final List columnNames; - - public Columns(final String[] header) { - columnNames = List.of(header); - } - - int indexOf(final String columnName) { - int index = columnNames.indexOf(columnName); - if (index < 0) { - throw new RuntimeException("column name '" + columnName + "' not found in: " + columnNames); - } - return index; - } -} - -class Record { - - private final Columns columns; - private final String[] row; - - public Record(final Columns columns, final String[] row) { - this.columns = columns; - this.row = row; - } - - String getString(final String columnName) { - return row[columns.indexOf(columnName)]; - } - - boolean isEmpty(final String columnName) { - final String value = getString(columnName); - return value == null || value.isBlank(); - } - - Byte getByte(final String columnName) { - final String value = getString(columnName); - return isNotBlank(value) ? Byte.valueOf(value.trim()) : 0; - } - - boolean getBoolean(final String columnName) { - final String value = getString(columnName); - return isNotBlank(value) && Boolean.parseBoolean(value.trim()); - } - - Integer getInteger(final String columnName) { - final String value = getString(columnName); - return isNotBlank(value) ? Integer.parseInt(value.trim()) : 0; - } - - BigDecimal getBigDecimal(final String columnName) { - final String value = getString(columnName); - if (isNotBlank(value)) { - return new BigDecimal(value); - } - return null; - } - - LocalDate getLocalDate(final String columnName) { - final String dateString = getString(columnName); - if (isNotBlank(dateString)) { - return LocalDate.parse(dateString); - } - return null; - } -} - -class OrderedDependedTestsExtension implements TestWatcher, BeforeEachCallback { - - private static boolean previousTestsPassed = true; - - public void testFailed(ExtensionContext context, Throwable cause) { - previousTestsPassed = false; - } - - @Override - public void beforeEach(final ExtensionContext extensionContext) throws Exception { - assumeThat(previousTestsPassed).isTrue(); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java index fe517ee6..fc7287e4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java @@ -3,67 +3,62 @@ package net.hostsharing.hsadminng.hs.office.partner; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; -import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; -import org.json.JSONException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelation; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.transaction.annotation.Transactional; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.EX_PARTNER; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.startsWith; +import static org.hamcrest.Matchers.*; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { HsadminNgApplication.class, JpaAttempt.class } ) -class HsOfficePartnerControllerAcceptanceTest { +class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + private static final UUID GIVEN_NON_EXISTING_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); @LocalServerPort private Integer port; - @Autowired - Context context; - - @Autowired - Context contextMock; - @Autowired HsOfficePartnerRepository partnerRepo; + @Autowired + HsOfficeRelationRealRepository relationRepo; + @Autowired HsOfficePersonRepository personRepo; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRealRepository contactrealRepo; @Autowired JpaAttempt jpaAttempt; - @PersistenceContext - EntityManager em; - @Nested - @Accepts({ "Partner:F(Find)" }) @Transactional class ListPartners { @Test - void globalAdmin_withoutAssumedRoles_canViewAllPartners_ifNoCriteriaGiven() throws JSONException { + void globalAdmin_withoutAssumedRoles_canViewAllPartners_ifNoCriteriaGiven() { RestAssured // @formatter:off .given() @@ -75,40 +70,19 @@ class HsOfficePartnerControllerAcceptanceTest { .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" - [ - { - "person": { "familyName": "Smith" }, - "contact": { "label": "fifth contact" }, - "details": { "birthday": "1987-10-31" } - }, - { - "person": { "tradeName": "First GmbH" }, - "contact": { "label": "first contact" }, - "details": { "registrationOffice": "Hamburg" } - }, - { - "person": { "tradeName": "Third OHG" }, - "contact": { "label": "third contact" }, - "details": { "registrationOffice": "Hamburg" } - }, - { - "person": { "tradeName": "Second e.K." }, - "contact": { "label": "second contact" }, - "details": { "registrationOffice": "Hamburg" } - }, - { - "person": { "personType": "INCORPORATED_FIRM" }, - "contact": { "label": "forth contact" }, - "details": { "registrationOffice": "Hamburg" } - } - ] - """)); + [ + { partnerNumber: 10001 }, + { partnerNumber: 10002 }, + { partnerNumber: 10003 }, + { partnerNumber: 10004 }, + { partnerNumber: 10010 } + ] + """)); // @formatter:on } } @Nested - @Accepts({ "Partner:C(Create)" }) @Transactional class AddPartner { @@ -116,35 +90,53 @@ class HsOfficePartnerControllerAcceptanceTest { void globalAdmin_withoutAssumedRole_canAddPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenMandantPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream().findFirst().orElseThrow(); + final var givenPerson = personRepo.findPersonByOptionalNameLike("Third").stream().findFirst().orElseThrow(); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").stream().findFirst().orElseThrow(); final var location = RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerNumber": "12345", - "contactUuid": "%s", - "personUuid": "%s", - "details": { - "registrationOffice": "Temp Registergericht Aurich", - "registrationNumber": "111111" - } - } - """.formatted(givenContact.getUuid(), givenPerson.getUuid())) + { + "partnerNumber": "20002", + "partnerRel": { + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "111111" + } + } + """.formatted( + givenMandantPerson.getUuid(), + givenPerson.getUuid(), + givenContact.getUuid())) .port(port) .when() .post("http://localhost/api/hs/office/partners") - .then().assertThat() + .then().log().body().assertThat() .statusCode(201) .contentType(ContentType.JSON) - .body("uuid", isUuidValid()) - .body("details.registrationOffice", is("Temp Registergericht Aurich")) - .body("details.registrationNumber", is("111111")) - .body("contact.label", is(givenContact.getLabel())) - .body("person.tradeName", is(givenPerson.getTradeName())) + .body("", lenientlyEquals(""" + { + "partnerNumber": 20002, + "partnerRel": { + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "Third OHG" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "fourth contact" } + }, + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "111111" + } + } + """)) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on @@ -158,27 +150,37 @@ class HsOfficePartnerControllerAcceptanceTest { void globalAdmin_canNotAddPartner_ifContactDoesNotExist() { context.define("superuser-alex@hostsharing.net"); + final var givenMandantPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").get(0); final var givenPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); - final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); final var location = RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerNumber": "12345", - "contactUuid": "%s", - "personUuid": "%s", - "details": {} - } - """.formatted(givenContactUuid, givenPerson.getUuid())) + { + "partnerNumber": "20003", + "partnerRel": { + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "personUuid": "%s", + "contactUuid": "%s", + "details": {} + } + """.formatted( + givenMandantPerson.getUuid(), + givenPerson.getUuid(), + GIVEN_NON_EXISTING_UUID, + givenPerson.getUuid(), + GIVEN_NON_EXISTING_UUID)) .port(port) .when() .post("http://localhost/api/hs/office/partners") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find Contact with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [400] Unable to find " + HsOfficeContactRealEntity.class.getName() + " with id " + GIVEN_NON_EXISTING_UUID)); // @formatter:on } @@ -186,39 +188,52 @@ class HsOfficePartnerControllerAcceptanceTest { void globalAdmin_canNotAddPartner_ifPersonDoesNotExist() { context.define("superuser-alex@hostsharing.net"); - final var givenPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var mandantPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var location = RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "partnerNumber": "12345", - "contactUuid": "%s", - "personUuid": "%s", - "details": {} - } - """.formatted(givenContact.getUuid(), givenPersonUuid)) + { + "partnerNumber": "20004", + "partnerRel": { + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "personUuid": "%s", + "contactUuid": "%s", + "details": {} + } + """.formatted( + mandantPerson.getUuid(), + GIVEN_NON_EXISTING_UUID, + givenContact.getUuid(), + GIVEN_NON_EXISTING_UUID, + givenContact.getUuid())) .port(port) .when() .post("http://localhost/api/hs/office/partners") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find Person with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // TODO.impl: we want this error message: + // .body("message", is("ERROR: [400] Unable to find Person by uuid: " + GIVEN_NON_EXISTING_UUID)); + // but ModelMapper creates this error message: + .body("message", is("ERROR: [400] Unable to find " + HsOfficePersonEntity.class.getName() + " with id " + GIVEN_NON_EXISTING_UUID)); // @formatter:on } } @Nested - @Accepts({ "Partner:R(Read)" }) @Transactional class GetPartner { @Test void globalAdmin_withoutAssumedRole_canGetArbitraryPartner() { context.define("superuser-alex@hostsharing.net"); + final var partners = partnerRepo.findAll(); final var givenPartnerUuid = partnerRepo.findPartnerByOptionalNameLike("First").get(0).getUuid(); RestAssured // @formatter:off @@ -232,14 +247,23 @@ class HsOfficePartnerControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "person": { "tradeName": "First GmbH" }, - "contact": { "label": "first contact" } + "partnerNumber": 10001, + "partnerRel": { + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "First GmbH" }, + "type": "PARTNER", + "contact": { "caption": "first contact" } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789" + } + } } """)); // @formatter:on } @Test - @Accepts({ "Partner:X(Access Control)" }) void normalUser_canNotGetUnrelatedPartner() { context.define("superuser-alex@hostsharing.net"); final var givenPartnerUuid = partnerRepo.findPartnerByOptionalNameLike("First").get(0).getUuid(); @@ -255,7 +279,6 @@ class HsOfficePartnerControllerAcceptanceTest { } @Test - @Accepts({ "Partner:X(Access Control)" }) void contactAdminUser_canGetRelatedPartner() { context.define("superuser-alex@hostsharing.net"); final var givenPartnerUuid = partnerRepo.findPartnerByOptionalNameLike("first contact").get(0).getUuid(); @@ -271,15 +294,16 @@ class HsOfficePartnerControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "person": { "tradeName": "First GmbH" }, - "contact": { "label": "first contact" } + "partnerRel": { + "holder": { "tradeName": "First GmbH" }, + "contact": { "caption": "first contact" } + } } """)); // @formatter:on } } @Nested - @Accepts({ "Partner:U(Update)" }) @Transactional class PatchPartner { @@ -287,60 +311,113 @@ class HsOfficePartnerControllerAcceptanceTest { void globalAdmin_withoutAssumedRole_canPatchAllPropertiesOfArbitraryPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(); - final var givenPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenPartner = givenSomeTemporaryPartnerBessler(20011); + final var givenPartnerRel = givenSomeTemporaryPartnerRel("Third OHG", "third contact"); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" - { - "debitorNumerPrefix": "12345", - "contactUuid": "%s", - "personUuid": "%s", - "details": { - "registrationOffice": "Temp Registergericht Aurich", - "registrationNumber": "222222", - "birthName": "Maja Schmidt", - "birthday": "1938-04-08", - "dateOfDeath": "2022-01-12" - } - } - """.formatted(givenContact.getUuid(), givenPerson.getUuid())) + { + "partnerNumber": "20011", + "partnerRelUuid": "%s", + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "222222", + "birthName": "Maja Schmidt", + "birthday": "1938-04-08", + "dateOfDeath": "2022-01-12" + } + } + """.formatted(givenPartnerRel.getUuid())) .port(port) .when() .patch("http://localhost/api/hs/office/partners/" + givenPartner.getUuid()) - .then().assertThat() + .then().log().body().assertThat() .statusCode(200) .contentType(ContentType.JSON) - .body("uuid", isUuidValid()) - .body("details.registrationNumber", is("222222")) - .body("contact.label", is(givenContact.getLabel())) - .body("person.tradeName", is(givenPerson.getTradeName())); + .body("", lenientlyEquals(""" + { + "partnerNumber": 20011, + "partnerRel": { + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "Third OHG" }, + "type": "PARTNER", + "contact": { "caption": "third contact" } + }, + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "222222", + "birthName": "Maja Schmidt", + "birthPlace": null, + "birthday": "1938-04-08", + "dateOfDeath": "2022-01-12" + } + } + """)); // @formatter:on // finally, the partner is actually updated context.define("superuser-alex@hostsharing.net"); assertThat(partnerRepo.findByUuid(givenPartner.getUuid())).isPresent().get() - .matches(person -> { - assertThat(person.getPerson().getTradeName()).isEqualTo("Third OHG"); - assertThat(person.getContact().getLabel()).isEqualTo("forth contact"); - assertThat(person.getDetails().getRegistrationOffice()).isEqualTo("Temp Registergericht Aurich"); - assertThat(person.getDetails().getRegistrationNumber()).isEqualTo("222222"); - assertThat(person.getDetails().getBirthName()).isEqualTo("Maja Schmidt"); - assertThat(person.getDetails().getBirthday()).isEqualTo("1938-04-08"); - assertThat(person.getDetails().getDateOfDeath()).isEqualTo("2022-01-12"); + .matches(partner -> { + assertThat(partner.getPartnerNumber()).isEqualTo(givenPartner.getPartnerNumber()); + assertThat(partner.getPartnerRel().getHolder().getTradeName()).isEqualTo("Third OHG"); + assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("third contact"); + assertThat(partner.getDetails().getRegistrationOffice()).isEqualTo("Temp Registergericht Aurich"); + assertThat(partner.getDetails().getRegistrationNumber()).isEqualTo("222222"); + assertThat(partner.getDetails().getBirthName()).isEqualTo("Maja Schmidt"); + assertThat(partner.getDetails().getBirthday()).isEqualTo("1938-04-08"); + assertThat(partner.getDetails().getDateOfDeath()).isEqualTo("2022-01-12"); return true; }); } + @Test + void patchingThePartnerRelCreatesExPartnerRel() { + + context.define("superuser-alex@hostsharing.net"); + final var givenPartner = givenSomeTemporaryPartnerBessler(20011); + final var givenPartnerRel = givenSomeTemporaryPartnerRel("Third OHG", "third contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "partnerRelUuid": "%s" + } + """.formatted(givenPartnerRel.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/office/partners/" + givenPartner.getUuid()) + .then().log().body() + .assertThat().statusCode(200); + // @formatter:on + + // then the partner got actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(partnerRepo.findByUuid(givenPartner.getUuid())).isPresent().get() + .matches(partner -> { + assertThat(partner.getPartnerRel().getHolder().getTradeName()).isEqualTo("Third OHG"); + assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("third contact"); + return true; + }); + + // and an ex-partner-relation got created + final var anchorpartnerPersonUUid = givenPartner.getPartnerRel().getAnchor().getUuid(); + assertThat(relationRepo.findRelationRelatedToPersonUuidAndRelationType(anchorpartnerPersonUUid, EX_PARTNER)) + .map(HsOfficeRelation::toShortString) + .contains("rel(anchor='LP Hostsharing eG', type='EX_PARTNER', holder='UF Erben Bessler')"); + } + @Test void globalAdmin_withoutAssumedRole_canPatchPartialPropertiesOfArbitraryPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(); + final var givenPartner = givenSomeTemporaryPartnerBessler(20012); final var location = RestAssured // @formatter:off .given() @@ -363,20 +440,18 @@ class HsOfficePartnerControllerAcceptanceTest { .contentType(ContentType.JSON) .body("uuid", isUuidValid()) .body("details.birthName", is("Maja Schmidt")) - .body("contact.label", is(givenPartner.getContact().getLabel())) - .body("person.tradeName", is(givenPartner.getPerson().getTradeName())); + .body("partnerRel.contact.caption", is(givenPartner.getPartnerRel().getContact().getCaption())); // @formatter:on - // finally, the partner is actually updated + // finally, the partner details and only the partner details are actually updated assertThat(partnerRepo.findByUuid(givenPartner.getUuid())).isPresent().get() - .matches(person -> { - assertThat(person.getPerson().getTradeName()).isEqualTo(givenPartner.getPerson().getTradeName()); - assertThat(person.getContact().getLabel()).isEqualTo(givenPartner.getContact().getLabel()); - assertThat(person.getDetails().getRegistrationOffice()).isEqualTo("Temp Registergericht Leer"); - assertThat(person.getDetails().getRegistrationNumber()).isEqualTo("333333"); - assertThat(person.getDetails().getBirthName()).isEqualTo("Maja Schmidt"); - assertThat(person.getDetails().getBirthday()).isEqualTo("1938-04-08"); - assertThat(person.getDetails().getDateOfDeath()).isEqualTo("2022-01-12"); + .matches(partner -> { + assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo(givenPartner.getPartnerRel().getContact().getCaption()); + assertThat(partner.getDetails().getRegistrationOffice()).isEqualTo("Temp Registergericht Leer"); + assertThat(partner.getDetails().getRegistrationNumber()).isEqualTo("333333"); + assertThat(partner.getDetails().getBirthName()).isEqualTo("Maja Schmidt"); + assertThat(partner.getDetails().getBirthday()).isEqualTo("1938-04-08"); + assertThat(partner.getDetails().getDateOfDeath()).isEqualTo("2022-01-12"); return true; }); } @@ -384,14 +459,13 @@ class HsOfficePartnerControllerAcceptanceTest { } @Nested - @Accepts({ "Partner:D(Delete)" }) @Transactional class DeletePartner { @Test void globalAdmin_withoutAssumedRole_canDeleteArbitraryPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(); + final var givenPartner = givenSomeTemporaryPartnerBessler(20013); RestAssured // @formatter:off .given() @@ -404,18 +478,18 @@ class HsOfficePartnerControllerAcceptanceTest { // then the given partner is gone assertThat(partnerRepo.findByUuid(givenPartner.getUuid())).isEmpty(); + assertThat(relationRepo.findByUuid(givenPartner.getPartnerRel().getUuid())).isEmpty(); } @Test - @Accepts({ "Partner:X(Access Control)" }) void contactAdminUser_canNotDeleteRelatedPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(); - assertThat(givenPartner.getContact().getLabel()).isEqualTo("forth contact"); + final var givenPartner = givenSomeTemporaryPartnerBessler(20014); + assertThat(givenPartner.getPartnerRel().getContact().getCaption()).isEqualTo("fourth contact"); RestAssured // @formatter:off .given() - .header("current-user", "contact-admin@forthcontact.example.com") + .header("current-user", "contact-admin@fourthcontact.example.com") .port(port) .when() .delete("http://localhost/api/hs/office/partners/" + givenPartner.getUuid()) @@ -427,11 +501,10 @@ class HsOfficePartnerControllerAcceptanceTest { } @Test - @Accepts({ "Partner:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedPartner() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(); - assertThat(givenPartner.getContact().getLabel()).isEqualTo("forth contact"); + final var givenPartner = givenSomeTemporaryPartnerBessler(20015); + assertThat(givenPartner.getPartnerRel().getContact().getCaption()).isEqualTo("fourth contact"); RestAssured // @formatter:off .given() @@ -447,47 +520,47 @@ class HsOfficePartnerControllerAcceptanceTest { } } - private HsOfficePartnerEntity givenSomeTemporaryPartnerBessler() { + private HsOfficeRelationRealEntity givenSomeTemporaryPartnerRel( + final String partnerHolderName, + final String contactName) { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var givenPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var givenMandantPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream().findFirst().orElseThrow(); + final var givenPerson = personRepo.findPersonByOptionalNameLike(partnerHolderName).stream().findFirst().orElseThrow(); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike(contactName).stream().findFirst().orElseThrow(); + + final var partnerRel = new HsOfficeRelationRealEntity(); + partnerRel.setType(HsOfficeRelationType.PARTNER); + partnerRel.setAnchor(givenMandantPerson); + partnerRel.setHolder(givenPerson); + partnerRel.setContact(givenContact); + em.persist(partnerRel); + return partnerRel; + }).assertSuccessful().returnedValue(); + } + private HsOfficePartnerEntity givenSomeTemporaryPartnerBessler(final Integer partnerNumber) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var partnerRel = em.merge(givenSomeTemporaryPartnerRel("Erben Bessler", "fourth contact")); + final var newPartner = HsOfficePartnerEntity.builder() - .person(givenPerson) - .contact(givenContact) + .partnerRel(partnerRel) + .partnerNumber(partnerNumber) .details(HsOfficePartnerDetailsEntity.builder() .registrationOffice("Temp Registergericht Leer") .registrationNumber("333333") .build()) .build(); - return partnerRepo.save(newPartner); + return partnerRepo.save(newPartner).load(); }).assertSuccessful().returnedValue(); } @AfterEach void cleanup() { - final var deleted = jpaAttempt.transacted(() -> { - context.define("superuser-alex@hostsharing.net", null); - em.createNativeQuery(""" - delete from hs_office_partner p - where p.detailsuuid in ( - select d.uuid from hs_office_partner_details d - where d.registrationoffice like 'Temp %') - """) - .executeUpdate(); - }).assertSuccessful().returnedValue(); + cleanupAllNew(HsOfficePartnerEntity.class); - final var remaining = jpaAttempt.transacted(() -> { - em.createNativeQuery(""" - select count(p) from hs_office_partner p - where p.detailsuuid in ( - select d.uuid from hs_office_partner_details d - where d.registrationoffice like 'Temp %') - """) - .getSingleResult(); - }).assertSuccessful().returnedValue(); - System.err.println("@AfterEach" + ": " + deleted + " records deleted, " + remaining + " remaining"); + // TODO: should not be necessary anymore, once it's deleted via after delete trigger + cleanupAllNew(HsOfficeRelationRealEntity.class); } - } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerRestTest.java new file mode 100644 index 00000000..97b56052 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerRestTest.java @@ -0,0 +1,195 @@ +package net.hostsharing.hsadminng.hs.office.partner; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.SynchronizationType; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsOfficePartnerController.class) +@Import(Mapper.class) +class HsOfficePartnerControllerRestTest { + + static final UUID GIVEN_MANDANTE_UUID = UUID.randomUUID(); + static final UUID GIVEN_PERSON_UUID = UUID.randomUUID(); + static final UUID GIVEN_CONTACT_UUID = UUID.randomUUID(); + static final UUID GIVEN_INVALID_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @MockBean + HsOfficePartnerRepository partnerRepo; + + @MockBean + HsOfficeRelationRealRepository relationRepo; + + @MockBean + EntityManager em; + + @MockBean + EntityManagerFactory emf; + + @Mock + HsOfficePersonEntity mandateMock; + + @Mock + HsOfficePersonEntity personMock; + + @Mock + HsOfficeContactRbacEntity contactMock; + + @Mock + HsOfficePartnerEntity partnerMock; + + @BeforeEach + void init() { + when(emf.createEntityManager()).thenReturn(em); + when(emf.createEntityManager(any(Map.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em); + + lenient().when(em.getReference(HsOfficePersonEntity.class, GIVEN_MANDANTE_UUID)).thenReturn(mandateMock); + lenient().when(em.getReference(HsOfficePersonEntity.class, GIVEN_PERSON_UUID)).thenReturn(personMock); + lenient().when(em.getReference(HsOfficeContactRbacEntity.class, GIVEN_CONTACT_UUID)).thenReturn(contactMock); + lenient().when(em.getReference(any(), eq(GIVEN_INVALID_UUID))).thenThrow(EntityNotFoundException.class); + } + + @Nested + class AddPartner { + + @Test + void respondBadRequest_ifPersonUuidIsInvalid() throws Exception { + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/office/partners") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "partnerNumber": "20002", + "partnerRel": { + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "personUuid": "%s", + "contactUuid": "%s", + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "111111" + } + } + """.formatted( + GIVEN_MANDANTE_UUID, + GIVEN_INVALID_UUID, + GIVEN_CONTACT_UUID, + GIVEN_INVALID_UUID, + GIVEN_CONTACT_UUID)) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("statusCode", is(400))) + .andExpect(jsonPath("statusPhrase", is("Bad Request"))) + .andExpect(jsonPath("message", startsWith("ERROR: [400] Cannot resolve HsOfficePersonEntity with uuid "))); + } + + @Test + void respondBadRequest_ifContactUuidIsInvalid() throws Exception { + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/office/partners") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "partnerNumber": "20002", + "partnerRel": { + "anchorUuid": "%s", + "holderUuid": "%s", + "contactUuid": "%s" + }, + "personUuid": "%s", + "contactUuid": "%s", + "details": { + "registrationOffice": "Temp Registergericht Aurich", + "registrationNumber": "111111" + } + } + """.formatted( + GIVEN_MANDANTE_UUID, + GIVEN_PERSON_UUID, + GIVEN_INVALID_UUID, + GIVEN_PERSON_UUID, + GIVEN_INVALID_UUID)) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("statusCode", is(400))) + .andExpect(jsonPath("statusPhrase", is("Bad Request"))) + .andExpect(jsonPath("message", startsWith("ERROR: [400] Cannot resolve HsOfficeContactRealEntity with uuid "))); + } + } + + @Nested + class DeletePartner { + + @Test + void respondBadRequest_ifPartnerCannotBeDeleted() throws Exception { + // given + final UUID givenPartnerUuid = UUID.randomUUID(); + when(partnerRepo.findByUuid(givenPartnerUuid)).thenReturn(Optional.of(partnerMock)); + when(partnerRepo.deleteByUuid(givenPartnerUuid)).thenReturn(0); + + final UUID givenRelationUuid = UUID.randomUUID(); + when(partnerMock.getPartnerRel()).thenReturn(HsOfficeRelationRealEntity.builder() + .uuid(givenRelationUuid) + .build()); + when(relationRepo.deleteByUuid(givenRelationUuid)).thenReturn(0); + + // when + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/hs/office/partners/" + givenPartnerUuid) + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().isForbidden()); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java index 4f55d90b..8a3c0084 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java @@ -1,9 +1,9 @@ package net.hostsharing.hsadminng.hs.office.partner; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerDetailsPatchResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; @@ -43,8 +43,8 @@ class HsOfficePartnerDetailsEntityPatcherUnitTest extends PatchUnitTestBase< @BeforeEach void initMocks() { - lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> - HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeContactRbacEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactRbacEntity.builder().uuid(invocation.getArgument(1)).build()); lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation -> HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build()); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java index 5fe483ae..a2ed7ca5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java @@ -1,9 +1,10 @@ package net.hostsharing.hsadminng.hs.office.partner; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,13 +31,12 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase< private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID(); private static final UUID INITIAL_PERSON_UUID = UUID.randomUUID(); private static final UUID INITIAL_DETAILS_UUID = UUID.randomUUID(); - private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); - private static final UUID PATCHED_PERSON_UUID = UUID.randomUUID(); + private static final UUID PATCHED_PARTNER_ROLE_UUID = UUID.randomUUID(); private final HsOfficePersonEntity givenInitialPerson = HsOfficePersonEntity.builder() .uuid(INITIAL_PERSON_UUID) .build(); - private final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() + private final HsOfficeContactRealEntity givenInitialContact = HsOfficeContactRealEntity.builder() .uuid(INITIAL_CONTACT_UUID) .build(); @@ -48,19 +48,21 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase< @BeforeEach void initMocks() { - lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> - HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); - lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation -> - HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeRelationRealEntity.class), any())).thenAnswer(invocation -> + HsOfficeRelationRealEntity.builder().uuid(invocation.getArgument(1)).build()); } @Override protected HsOfficePartnerEntity newInitialEntity() { - final var entity = new HsOfficePartnerEntity(); - entity.setUuid(INITIAL_PARTNER_UUID); - entity.setPerson(givenInitialPerson); - entity.setContact(givenInitialContact); - entity.setDetails(givenInitialDetails); + final var entity = HsOfficePartnerEntity.builder() + .uuid(INITIAL_PARTNER_UUID) + .partnerNumber(12345) + .partnerRel(HsOfficeRelationRealEntity.builder() + .holder(givenInitialPerson) + .contact(givenInitialContact) + .build()) + .details(givenInitialDetails) + .build(); return entity; } @@ -78,31 +80,18 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase< protected Stream propertyTestDescriptors() { return Stream.of( new JsonNullableProperty<>( - "contact", - HsOfficePartnerPatchResource::setContactUuid, - PATCHED_CONTACT_UUID, - HsOfficePartnerEntity::setContact, - newContact(PATCHED_CONTACT_UUID)) - .notNullable(), - new JsonNullableProperty<>( - "person", - HsOfficePartnerPatchResource::setPersonUuid, - PATCHED_PERSON_UUID, - HsOfficePartnerEntity::setPerson, - newPerson(PATCHED_PERSON_UUID)) + "partnerRel", + HsOfficePartnerPatchResource::setPartnerRelUuid, + PATCHED_PARTNER_ROLE_UUID, + HsOfficePartnerEntity::setPartnerRel, + newPartnerRel(PATCHED_PARTNER_ROLE_UUID)) .notNullable() ); } - private static HsOfficeContactEntity newContact(final UUID uuid) { - final var newContact = new HsOfficeContactEntity(); - newContact.setUuid(uuid); - return newContact; - } - - private HsOfficePersonEntity newPerson(final UUID uuid) { - final var newPerson = new HsOfficePersonEntity(); - newPerson.setUuid(uuid); - return newPerson; + private static HsOfficeRelationRealEntity newPartnerRel(final UUID uuid) { + return HsOfficeRelationRealEntity.builder() + .uuid(uuid) + .build(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityUnitTest.java index a6d2c60a..3cf07cab 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityUnitTest.java @@ -1,41 +1,41 @@ package net.hostsharing.hsadminng.hs.office.partner; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class HsOfficePartnerEntityUnitTest { + private final HsOfficePartnerEntity givenPartner = HsOfficePartnerEntity.builder() + .partnerNumber(12345) + .partnerRel(HsOfficeRelationRealEntity.builder() + .anchor(HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.LEGAL_PERSON) + .tradeName("Hostsharing eG") + .build()) + .type(HsOfficeRelationType.PARTNER) + .holder(HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.LEGAL_PERSON) + .tradeName("some trade name") + .build()) + .contact(HsOfficeContactRealEntity.builder().caption("some caption").build()) + .build()) + .build(); + @Test - void toStringContainsPersonAndContact() { - final var given = HsOfficePartnerEntity.builder() - .person(HsOfficePersonEntity.builder() - .personType(HsOfficePersonType.LEGAL_PERSON) - .tradeName("some trade name") - .build()) - .contact(HsOfficeContactEntity.builder().label("some label").build()) - .build(); - - final var result = given.toString(); - - assertThat(result).isEqualTo("partner(LP some trade name: some label)"); + void toStringContainsPartnerNumberPersonAndContact() { + final var result = givenPartner.toString(); + assertThat(result).isEqualTo("partner(P-12345: LP some trade name, some caption)"); } @Test - void toShortStringContainsPersonAndContact() { - final var given = HsOfficePartnerEntity.builder() - .person(HsOfficePersonEntity.builder() - .personType(HsOfficePersonType.LEGAL_PERSON) - .tradeName("some trade name") - .build()) - .contact(HsOfficeContactEntity.builder().label("some label").build()) - .build(); - - final var result = given.toShortString(); - - assertThat(result).isEqualTo("LP some trade name"); + void toShortStringContainsPartnerNumber() { + final var result = givenPartner.toShortString(); + assertThat(result).isEqualTo("P-12345"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java index f764163d..2d871048 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java @@ -1,13 +1,16 @@ package net.hostsharing.hsadminng.hs.office.partner; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacObjectRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -21,27 +24,34 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; +import java.util.Objects; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacObjectEntity.objectDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.from; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficePartnerRepository partnerRepo; + @Autowired + HsOfficeRelationRealRepository relationRepo; + @Autowired HsOfficePersonRepository personRepo; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRealRepository contactrealRepo; + + @Autowired + RawRbacObjectRepository rawObjectRepo; @Autowired RawRbacRoleRepository rawRoleRepo; @@ -58,8 +68,6 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { @MockBean HttpServletRequest request; - Set tempPartners = new HashSet<>(); - @Nested class CreatePartner { @@ -68,17 +76,15 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { // given context("superuser-alex@hostsharing.net"); final var count = partnerRepo.count(); - final var givenPerson = personRepo.findPersonByOptionalNameLike("First GmbH").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("first contact").get(0); + final var partnerRel = givenSomeTemporaryHostsharingPartnerRel("First GmbH", "first contact"); // when final var result = attempt(em, () -> { - final var newPartner = toCleanup(HsOfficePartnerEntity.builder() - .person(givenPerson) - .contact(givenContact) - .details(HsOfficePartnerDetailsEntity.builder() - .build()) - .build()); + final var newPartner = HsOfficePartnerEntity.builder() + .partnerNumber(20031) + .partnerRel(partnerRel) + .details(HsOfficePartnerDetailsEntity.builder().build()) + .build(); return partnerRepo.save(newPartner); }); @@ -93,73 +99,88 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() .map(s -> s.replace("ErbenBesslerMelBessler", "EBess")) - .map(s -> s.replace("forthcontact", "4th")) + .map(s -> s.replace("fourthcontact", "4th")) .map(s -> s.replace("hs_office_", "")) .toList(); // when attempt(em, () -> { - final var givenPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); - final var newPartner = toCleanup(HsOfficePartnerEntity.builder() - .partnerNumber(22222) - .person(givenPerson) + final var givenPartnerPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth contact").get(0); + final var givenMandantPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").get(0); + + final var newRelation = HsOfficeRelationRealEntity.builder() + .holder(givenPartnerPerson) + .type(HsOfficeRelationType.PARTNER) + .anchor(givenMandantPerson) .contact(givenContact) + .build(); + relationRepo.save(newRelation); + + final var newPartner = HsOfficePartnerEntity.builder() + .partnerNumber(20032) + .partnerRel(newRelation) .details(HsOfficePartnerDetailsEntity.builder().build()) - .build()); + .build(); return partnerRepo.save(newPartner); - }); + }).assertSuccessful(); // then - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(from( initialRoleNames, - "hs_office_partner#22222:ErbenBesslerMelBessler-forthcontact.admin", - "hs_office_partner#22222:ErbenBesslerMelBessler-forthcontact.agent", - "hs_office_partner#22222:ErbenBesslerMelBessler-forthcontact.owner", - "hs_office_partner#22222:ErbenBesslerMelBessler-forthcontact.tenant", - "hs_office_partner#22222:ErbenBesslerMelBessler-forthcontact.guest")); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) + "hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:OWNER", + "hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:ADMIN", + "hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:AGENT", + "hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) .map(s -> s.replace("ErbenBesslerMelBessler", "EBess")) - .map(s -> s.replace("forthcontact", "4th")) + .map(s -> s.replace("fourthcontact", "4th")) .map(s -> s.replace("hs_office_", "")) - .containsExactlyInAnyOrder(Array.fromFormatted( + .containsExactlyInAnyOrder(distinct(from( initialGrantNames, - // owner - "{ grant perm * on partner#22222:EBess-4th to role partner#22222:EBess-4th.owner by system and assume }", - "{ grant perm * on partner_details#22222:EBess-4th-details to role partner#22222:EBess-4th.owner by system and assume }", - "{ grant role partner#22222:EBess-4th.owner to role global#global.admin by system and assume }", - // admin - "{ grant perm edit on partner#22222:EBess-4th to role partner#22222:EBess-4th.admin by system and assume }", - "{ grant perm edit on partner_details#22222:EBess-4th-details to role partner#22222:EBess-4th.admin by system and assume }", - "{ grant role partner#22222:EBess-4th.admin to role partner#22222:EBess-4th.owner by system and assume }", - "{ grant role person#EBess.tenant to role partner#22222:EBess-4th.admin by system and assume }", - "{ grant role contact#4th.tenant to role partner#22222:EBess-4th.admin by system and assume }", + // permissions on partner + "{ grant perm:partner#P-20032:DELETE to role:relation#HostsharingeG-with-PARTNER-EBess:OWNER by system and assume }", + "{ grant perm:partner#P-20032:UPDATE to role:relation#HostsharingeG-with-PARTNER-EBess:ADMIN by system and assume }", + "{ grant perm:partner#P-20032:SELECT to role:relation#HostsharingeG-with-PARTNER-EBess:TENANT by system and assume }", - // agent - "{ grant perm view on partner_details#22222:EBess-4th-details to role partner#22222:EBess-4th.agent by system and assume }", - "{ grant role partner#22222:EBess-4th.agent to role partner#22222:EBess-4th.admin by system and assume }", - "{ grant role partner#22222:EBess-4th.agent to role person#EBess.admin by system and assume }", - "{ grant role partner#22222:EBess-4th.agent to role contact#4th.admin by system and assume }", + // permissions on partner-details + "{ grant perm:partner_details#P-20032:DELETE to role:relation#HostsharingeG-with-PARTNER-EBess:OWNER by system and assume }", + "{ grant perm:partner_details#P-20032:UPDATE to role:relation#HostsharingeG-with-PARTNER-EBess:AGENT by system and assume }", + "{ grant perm:partner_details#P-20032:SELECT to role:relation#HostsharingeG-with-PARTNER-EBess:AGENT by system and assume }", - // tenant - "{ grant role partner#22222:EBess-4th.tenant to role partner#22222:EBess-4th.agent by system and assume }", - "{ grant role person#EBess.guest to role partner#22222:EBess-4th.tenant by system and assume }", - "{ grant role contact#4th.guest to role partner#22222:EBess-4th.tenant by system and assume }", + // permissions on partner-relation + "{ grant perm:relation#HostsharingeG-with-PARTNER-EBess:DELETE to role:relation#HostsharingeG-with-PARTNER-EBess:OWNER by system and assume }", + "{ grant perm:relation#HostsharingeG-with-PARTNER-EBess:UPDATE to role:relation#HostsharingeG-with-PARTNER-EBess:ADMIN by system and assume }", + "{ grant perm:relation#HostsharingeG-with-PARTNER-EBess:SELECT to role:relation#HostsharingeG-with-PARTNER-EBess:TENANT by system and assume }", - // guest - "{ grant perm view on partner#22222:EBess-4th to role partner#22222:EBess-4th.guest by system and assume }", - "{ grant role partner#22222:EBess-4th.guest to role partner#22222:EBess-4th.tenant by system and assume }", + // relation owner + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:OWNER to user:superuser-alex@hostsharing.net by relation#HostsharingeG-with-PARTNER-EBess:OWNER and assume }", - null)); + // relation admin + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:ADMIN to role:relation#HostsharingeG-with-PARTNER-EBess:OWNER by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:OWNER to role:person#HostsharingeG:ADMIN by system and assume }", + + // relation agent + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:AGENT to role:person#EBess:ADMIN by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:AGENT to role:relation#HostsharingeG-with-PARTNER-EBess:ADMIN by system and assume }", + + // relation tenant + "{ grant role:contact#4th:REFERRER to role:relation#HostsharingeG-with-PARTNER-EBess:TENANT by system and assume }", + "{ grant role:person#EBess:REFERRER to role:relation#HostsharingeG-with-PARTNER-EBess:TENANT by system and assume }", + "{ grant role:person#HostsharingeG:REFERRER to role:relation#HostsharingeG-with-PARTNER-EBess:TENANT by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:TENANT to role:contact#4th:ADMIN by system and assume }", + "{ grant role:relation#HostsharingeG-with-PARTNER-EBess:TENANT to role:relation#HostsharingeG-with-PARTNER-EBess:AGENT by system and assume }", + null))); } private void assertThatPartnerIsPersisted(final HsOfficePartnerEntity saved) { final var found = partnerRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -177,9 +198,11 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { // then allThesePartnersAreReturned( result, - "partner(IF Third OHG: third contact)", - "partner(LP Second e.K.: second contact)", - "partner(LP First GmbH: first contact)"); + "partner(P-10001: LP First GmbH, first contact)", + "partner(P-10002: LP Second e.K., second contact)", + "partner(P-10003: IF Third OHG, third contact)", + "partner(P-10004: LP Fourth eG, fourth contact)", + "partner(P-10010: NP Smith, Peter, sixth contact)"); } @Test @@ -191,7 +214,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { final var result = partnerRepo.findPartnerByOptionalNameLike(null); // then: - exactlyThesePartnersAreReturned(result, "partner(LP First GmbH: first contact)"); + exactlyThesePartnersAreReturned(result, "partner(P-10001: LP First GmbH, first contact)"); } } @@ -207,7 +230,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { final var result = partnerRepo.findPartnerByOptionalNameLike("third contact"); // then - exactlyThesePartnersAreReturned(result, "partner(IF Third OHG: third contact)"); + exactlyThesePartnersAreReturned(result, "partner(P-10003: IF Third OHG, third contact)"); } } @@ -226,7 +249,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { assertThat(result) .isNotNull() .extracting(Object::toString) - .isEqualTo("partner(LP First GmbH: first contact)"); + .isEqualTo("partner(P-10001: LP First GmbH, first contact)"); } } @@ -237,64 +260,82 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void hostsharingAdmin_withoutAssumedRole_canUpdateArbitraryPartner() { // given context("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(22222, "Erben Bessler", "fifth contact"); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20036, "Erben Bessler", "fifth contact"); assertThatPartnerIsVisibleForUserWithRole( givenPartner, - "hs_office_partner#22222:ErbenBesslerMelBessler-fifthcontact.admin"); + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); assertThatPartnerActuallyInDatabase(givenPartner); - context("superuser-alex@hostsharing.net"); - final var givenNewPerson = personRepo.findPersonByOptionalNameLike("Third OHG").get(0); - final var givenNewContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0); // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - givenPartner.setContact(givenNewContact); - givenPartner.setPerson(givenNewPerson); - return toCleanup(partnerRepo.save(givenPartner)); + givenPartner.setPartnerRel(givenSomeTemporaryHostsharingPartnerRel("Third OHG", "sixth contact")); + return partnerRepo.save(givenPartner); }); // then result.assertSuccessful(); - assertThatPartnerIsVisibleForUserWithRole( - result.returnedValue(), - "global#global.admin"); - assertThatPartnerIsVisibleForUserWithRole( - result.returnedValue(), - "hs_office_person#ThirdOHG.admin"); - assertThatPartnerIsNotVisibleForUserWithRole( - result.returnedValue(), - "hs_office_person#ErbenBesslerMelBessler.admin"); - partnerRepo.deleteByUuid(givenPartner.getUuid()); + assertThatPartnerIsVisibleForUserWithRole( + givenPartner, + "global#global:ADMIN"); + assertThatPartnerIsVisibleForUserWithRole( + givenPartner, + "hs_office_person#ThirdOHG:ADMIN"); + assertThatPartnerIsNotVisibleForUserWithRole( + givenPartner, + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); } @Test - public void partnerAgent_canNotUpdateRelatedPartner() { + public void partnerRelationAgent_canUpdateRelatedPartner() { // given context("superuser-alex@hostsharing.net"); - final var givenPartner = givenSomeTemporaryPartnerBessler(22222, "Erben Bessler", "ninth"); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20037, "Erben Bessler", "ninth"); assertThatPartnerIsVisibleForUserWithRole( givenPartner, - "hs_office_partner#22222:ErbenBesslerMelBessler-ninthcontact.agent"); + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); assertThatPartnerActuallyInDatabase(givenPartner); // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", - "hs_office_partner#22222:ErbenBesslerMelBessler-ninthcontact.agent"); + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); + givenPartner.getDetails().setBirthName("new birthname"); + return partnerRepo.save(givenPartner); + }); + + // then + result.assertSuccessful(); + } + + @Test + public void partnerRelationTenant_canNotUpdateRelatedPartner() { + // given + context("superuser-alex@hostsharing.net"); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20037, "Erben Bessler", "ninth"); + assertThatPartnerIsVisibleForUserWithRole( + givenPartner, + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); + assertThatPartnerActuallyInDatabase(givenPartner); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", + "hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:TENANT"); givenPartner.getDetails().setBirthName("new birthname"); return partnerRepo.save(givenPartner); }); // then result.assertExceptionWithRootCauseMessage(JpaSystemException.class, - "[403] Subject ", " is not allowed to update hs_office_partner_details uuid"); + "ERROR: [403] insert into hs_office_partner_details ", + " not allowed for current subjects {hs_office_relation#HostsharingeG-with-PARTNER-ErbenBesslerMelBessler:TENANT}"); } private void assertThatPartnerActuallyInDatabase(final HsOfficePartnerEntity saved) { final var found = partnerRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().isNotSameAs(saved).extracting(HsOfficePartnerEntity::toString).isEqualTo(saved.toString()); } private void assertThatPartnerIsVisibleForUserWithRole( @@ -324,7 +365,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void globalAdmin_withoutAssumedRole_canDeleteAnyPartner() { // given context("superuser-alex@hostsharing.net", null); - final var givenPartner = givenSomeTemporaryPartnerBessler(22222, "Erben Bessler", "tenth"); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20032, "Erben Bessler", "tenth"); // when final var result = jpaAttempt.transacted(() -> { @@ -344,7 +385,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void nonGlobalAdmin_canNotDeleteTheirRelatedPartner() { // given context("superuser-alex@hostsharing.net", null); - final var givenPartner = givenSomeTemporaryPartnerBessler(22222, "Erben Bessler", "eleventh"); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20033, "Erben Bessler", "eleventh"); // when final var result = jpaAttempt.transacted(() -> { @@ -368,9 +409,10 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void deletingAPartnerAlsoDeletesRelatedRolesAndGrants() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); - final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); - final var givenPartner = givenSomeTemporaryPartnerBessler(22222, "Erben Bessler", "twelfth"); + final var initialObjects = from(objectDisplaysOf(rawObjectRepo.findAll())); + final var initialRoleNames = from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenPartner = givenSomeTemporaryHostsharingPartner(20034, "Erben Bessler", "twelfth"); // when final var result = jpaAttempt.transacted(() -> { @@ -381,8 +423,9 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + assertThat(objectDisplaysOf(rawObjectRepo.findAll())).containsExactlyInAnyOrder(initialObjects); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); } } @@ -390,9 +433,8 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'partnernumber' + from tx_journal_v where targettable = 'hs_office_partner'; """); @@ -401,41 +443,42 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating partner test-data FirstGmbH-firstcontact, hs_office_partner, INSERT]", - "[creating partner test-data Seconde.K.-secondcontact, hs_office_partner, INSERT]"); + "[creating partner test-data , hs_office_partner, INSERT, 10001]", + "[creating partner test-data , hs_office_partner, INSERT, 10002]", + "[creating partner test-data , hs_office_partner, INSERT, 10003]", + "[creating partner test-data , hs_office_partner, INSERT, 10004]", + "[creating partner test-data , hs_office_partner, INSERT, 10010]"); } - @AfterEach - void cleanup() { - context("superuser-alex@hostsharing.net", null); - tempPartners.forEach(tempPartner -> { - System.out.println("DELETING temporary partner: " + tempPartner.toString()); - partnerRepo.deleteByUuid(tempPartner.getUuid()); - }); - } - - private HsOfficePartnerEntity givenSomeTemporaryPartnerBessler( + private HsOfficePartnerEntity givenSomeTemporaryHostsharingPartner( final Integer partnerNumber, final String person, final String contact) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - final var givenPerson = personRepo.findPersonByOptionalNameLike(person).get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0); + final var partnerRel = givenSomeTemporaryHostsharingPartnerRel(person, contact); + final var newPartner = HsOfficePartnerEntity.builder() .partnerNumber(partnerNumber) - .person(givenPerson) - .contact(givenContact) + .partnerRel(partnerRel) .details(HsOfficePartnerDetailsEntity.builder().build()) .build(); - toCleanup(newPartner); - return partnerRepo.save(newPartner); }).assertSuccessful().returnedValue(); } - private HsOfficePartnerEntity toCleanup(final HsOfficePartnerEntity tempPartner) { - tempPartners.add(tempPartner); - return tempPartner; + private HsOfficeRelationRealEntity givenSomeTemporaryHostsharingPartnerRel(final String person, final String contact) { + final var givenMandantorPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").get(0); + final var givenPartnerPerson = personRepo.findPersonByOptionalNameLike(person).get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike(contact).get(0); + + final var partnerRel = HsOfficeRelationRealEntity.builder() + .holder(givenPartnerPerson) + .type(HsOfficeRelationType.PARTNER) + .anchor(givenMandantorPerson) + .contact(givenContact) + .build(); + relationRepo.save(partnerRel).load(); + return partnerRel; } void exactlyThesePartnersAreReturned(final List actualResult, final String... partnerNames) { @@ -449,4 +492,14 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest { .extracting(partnerEntity -> partnerEntity.toString()) .contains(partnerNames); } + + @AfterEach + void cleanup() { + cleanupAllNew(HsOfficePartnerEntity.class); + } + + private String[] distinct(final String[] strings) { + // TODO: alternatively cleanup all rbac objects in @AfterEach? + return Arrays.stream(strings).filter(Objects::nonNull).distinct().toList().toArray(new String[0]); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/TestHsOfficePartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/TestHsOfficePartner.java index abbb8e09..1d3b8164 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/TestHsOfficePartner.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/TestHsOfficePartner.java @@ -1,8 +1,9 @@ package net.hostsharing.hsadminng.hs.office.partner; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; - +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON; @@ -13,13 +14,22 @@ public class TestHsOfficePartner { static public HsOfficePartnerEntity hsOfficePartnerWithLegalPerson(final String tradeName) { return HsOfficePartnerEntity.builder() .partnerNumber(10001) - .person(HsOfficePersonEntity.builder() - .personType(LEGAL_PERSON) - .tradeName(tradeName) - .build()) - .contact(HsOfficeContactEntity.builder() - .label(tradeName) - .build()) + .partnerRel( + HsOfficeRelationRealEntity.builder() + .holder(HsOfficePersonEntity.builder() + .personType(LEGAL_PERSON) + .tradeName("Hostsharing eG") + .build()) + .type(HsOfficeRelationType.PARTNER) + .holder(HsOfficePersonEntity.builder() + .personType(LEGAL_PERSON) + .tradeName(tradeName) + .build()) + .contact(HsOfficeContactRealEntity.builder() + .caption(tradeName) + .build()) + .build() + ) .build(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java index 6b505241..4a136331 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java @@ -4,10 +4,9 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; -import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,17 +19,16 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.startsWith; +import static org.hamcrest.Matchers.*; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { HsadminNgApplication.class, JpaAttempt.class } ) -class HsOfficePersonControllerAcceptanceTest { +class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort private Integer port; @@ -51,11 +49,10 @@ class HsOfficePersonControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "Person:F(Find)" }) class ListPersons { @Test - void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() throws JSONException { + void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() { RestAssured // @formatter:off .given() @@ -66,65 +63,12 @@ class HsOfficePersonControllerAcceptanceTest { .then().log().all().assertThat() .statusCode(200) .contentType("application/json") - .body("", lenientlyEquals(""" - [ - { - "personType": "LEGAL_PERSON", - "tradeName": "First GmbH", - "givenName": null, - "familyName": null - }, - { - "personType": "LEGAL_PERSON", - "tradeName": "Second e.K.", - "givenName": "Miller", - "familyName": "Sandra" - }, - { - "personType": "INCORPORATED_FIRM", - "tradeName": "Third OHG", - "givenName": null, - "familyName": null - }, - { - "personType": "INCORPORATED_FIRM", - "tradeName": "Fourth e.G.", - "givenName": null, - "familyName": null - }, - { - "personType": "NATURAL_PERSON", - "tradeName": null, - "givenName": "Anita", - "familyName": "Bessler" - }, - { - "personType": "UNINCORPORATED_FIRM", - "tradeName": "Erben Bessler", - "givenName": "Bessler", - "familyName": "Mel" - }, - { - "personType": "NATURAL_PERSON", - "tradeName": null, - "givenName": "Peter", - "familyName": "Smith" - }, - { - "personType": "NATURAL_PERSON", - "tradeName": null, - "givenName": "Paul", - "familyName": "Winkler" - } - ] - """ - )); + .body("", hasSize(13)); // @formatter:on } } @Nested - @Accepts({ "Person:C(Create)" }) class AddPerson { @Test @@ -162,7 +106,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Nested - @Accepts({ "Person:R(Read)" }) @Transactional class GetPerson { @@ -188,7 +131,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Test - @Accepts({ "Person:X(Access Control)" }) void normalUser_canNotGetUnrelatedPerson() { final var givenPersonUuid = jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); @@ -206,7 +148,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Test - @Accepts({ "Person:X(Access Control)" }) void personOwnerUser_canGetRelatedPerson() { final var givenPersonUuid = jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); @@ -234,7 +175,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Nested - @Accepts({ "Person:U(Update)" }) @Transactional class PatchPerson { @@ -322,7 +262,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Nested - @Accepts({ "Person:D(Delete)" }) @Transactional class DeletePerson { @@ -346,7 +285,6 @@ class HsOfficePersonControllerAcceptanceTest { } @Test - @Accepts({ "Person:X(Access Control)" }) void personOwner_canDeleteRelatedPerson() { final var givenPerson = givenSomeTemporaryPersonCreatedBy("selfregistered-test-user@hostsharing.org"); @@ -360,11 +298,13 @@ class HsOfficePersonControllerAcceptanceTest { .statusCode(204); // @formatter:on // then the given person is still there - assertThat(personRepo.findByUuid(givenPerson.getUuid())).isEmpty(); + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + assertThat(personRepo.findByUuid(givenPerson.getUuid())).isEmpty(); + }).assertSuccessful(); } @Test - @Accepts({ "Person:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedPerson() { final var givenPerson = givenSomeTemporaryPersonCreatedBy("selfregistered-test-user@hostsharing.org"); @@ -395,7 +335,7 @@ class HsOfficePersonControllerAcceptanceTest { .givenName("Temp Given Name " + RandomStringUtils.randomAlphabetic(10)) .build(); - return personRepo.save(newPerson); + return personRepo.save(newPerson).load(); }).assertSuccessful().returnedValue(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcherUnitTest.java index 7fdb0a27..39dabaa7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityPatcherUnitTest.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.hs.office.person; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource; import org.junit.jupiter.api.TestInstance; @@ -23,7 +23,9 @@ class HsOfficePersonEntityPatcherUnitTest extends PatchUnitTestBase< final var entity = new HsOfficePersonEntity(); entity.setUuid(INITIAL_PERSON_UUID); entity.setPersonType(HsOfficePersonType.LEGAL_PERSON); - entity.setTradeName("initial@example.org"); + entity.setTradeName("initial trade name"); + entity.setTitle("Dr. Init."); + entity.setSalutation("Herr Initial"); entity.setFamilyName("initial postal address"); entity.setGivenName("+01 100 123456789"); return entity; @@ -54,6 +56,16 @@ class HsOfficePersonEntityPatcherUnitTest extends PatchUnitTestBase< HsOfficePersonPatchResource::setTradeName, "patched trade name", HsOfficePersonEntity::setTradeName), + new JsonNullableProperty<>( + "title", + HsOfficePersonPatchResource::setTitle, + "Dr. Patch.", + HsOfficePersonEntity::setTitle), + new JsonNullableProperty<>( + "salutation", + HsOfficePersonPatchResource::setSalutation, + "Hallo Ini", + HsOfficePersonEntity::setSalutation), new JsonNullableProperty<>( "familyName", HsOfficePersonPatchResource::setFamilyName, diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java index 1eec872b..199e7f23 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java @@ -60,19 +60,63 @@ class HsOfficePersonEntityUnitTest { assertThat(actualDisplay).isEqualTo("NP some family name, some given name"); } + @Test + void toShortStringWithSalutationAndTitleReturnsSalutationAndTitle() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.NATURAL_PERSON) + .salutation("Frau") + .title("Dr.") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toShortString(); + + assertThat(actualDisplay).isEqualTo("NP some family name, some given name"); + } + + @Test + void toShortStringWithSalutationAndWithoutTitleReturnsSalutation() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.NATURAL_PERSON) + .salutation("Frau") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toShortString(); + + assertThat(actualDisplay).isEqualTo("NP some family name, some given name"); + } + + @Test + void toShortStringWithoutSalutationAndWithTitleReturnsTitle() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.NATURAL_PERSON) + .title("Dr. Dr.") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toShortString(); + + assertThat(actualDisplay).isEqualTo("NP some family name, some given name"); + } + @Test void toStringWithAllFieldsReturnsAllButUuid() { final var givenPersonEntity = HsOfficePersonEntity.builder() .uuid(UUID.randomUUID()) .personType(HsOfficePersonType.NATURAL_PERSON) .tradeName("some trade name") + .title("Dr.") .familyName("some family name") .givenName("some given name") .build(); final var actualDisplay = givenPersonEntity.toString(); - assertThat(actualDisplay).isEqualTo("person(personType='NP', tradeName='some trade name', familyName='some family name', givenName='some given name')"); + assertThat(actualDisplay).isEqualTo("person(personType='NP', tradeName='some trade name', title='Dr.', familyName='some family name', givenName='some given name')"); } @Test @@ -86,4 +130,42 @@ class HsOfficePersonEntityUnitTest { assertThat(actualDisplay).isEqualTo("person(familyName='some family name', givenName='some given name')"); } + @Test + void toStringWithSalutationAndTitleRetursSalutationAndTitle() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .salutation("Herr") + .title("Prof. Dr.") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toString(); + + assertThat(actualDisplay).isEqualTo("person(salutation='Herr', title='Prof. Dr.', familyName='some family name', givenName='some given name')"); + } + @Test + void toStringWithSalutationAndWithoutTitleSkipsTitle() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .salutation("Herr") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toString(); + + assertThat(actualDisplay).isEqualTo("person(salutation='Herr', familyName='some family name', givenName='some given name')"); + } + @Test + void toStringWithoutSalutationAndWithTitleSkipsSalutation() { + final var givenPersonEntity = HsOfficePersonEntity.builder() + .title("some title") + .familyName("some family name") + .givenName("some given name") + .build(); + + final var actualDisplay = givenPersonEntity.toString(); + + assertThat(actualDisplay).isEqualTo("person(title='some title', familyName='some family name', givenName='some given name')"); + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java index 8d7eace2..6ee4f486 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java @@ -1,13 +1,12 @@ package net.hostsharing.hsadminng.hs.office.person; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,14 +22,14 @@ import java.util.List; import java.util.function.Supplier; import static net.hostsharing.hsadminng.hs.office.person.TestHsOfficePerson.hsOfficePerson; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficePersonRepository personRepo; @@ -60,9 +59,8 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { final var count = personRepo.count(); // when - - final var result = attempt(em, () -> personRepo.save( - hsOfficePerson("a new person"))); + final var result = attempt(em, () -> toCleanup(personRepo.save( + hsOfficePerson("a new person")))); // then result.assertSuccessful(); @@ -78,8 +76,8 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { final var count = personRepo.count(); // when - final var result = attempt(em, () -> personRepo.save( - hsOfficePerson("another new person"))); + final var result = attempt(em, () -> toCleanup(personRepo.save( + hsOfficePerson("another new person")))); // then result.assertSuccessful(); @@ -92,41 +90,41 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("selfregistered-user-drew@hostsharing.org"); - final var count = personRepo.count(); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); // when - attempt(em, () -> personRepo.save( - hsOfficePerson("another new person")) - ).assumeSuccessful(); + attempt(em, () -> toCleanup( + personRepo.save(hsOfficePerson("another new person")) + )).assumeSuccessful(); // then - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder( + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder( Array.from( initialRoleNames, - "hs_office_person#anothernewperson.owner", - "hs_office_person#anothernewperson.admin", - "hs_office_person#anothernewperson.tenant", - "hs_office_person#anothernewperson.guest" + "hs_office_person#anothernewperson:OWNER", + "hs_office_person#anothernewperson:ADMIN", + "hs_office_person#anothernewperson:REFERRER" )); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder( - Array.from( + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder( + Array.fromFormatted( initialGrantNames, - "{ grant role hs_office_person#anothernewperson.owner to role global#global.admin by system and assume }", - "{ grant perm edit on hs_office_person#anothernewperson to role hs_office_person#anothernewperson.admin by system and assume }", - "{ grant role hs_office_person#anothernewperson.tenant to role hs_office_person#anothernewperson.admin by system and assume }", - "{ grant perm * on hs_office_person#anothernewperson to role hs_office_person#anothernewperson.owner by system and assume }", - "{ grant role hs_office_person#anothernewperson.admin to role hs_office_person#anothernewperson.owner by system and assume }", - "{ grant perm view on hs_office_person#anothernewperson to role hs_office_person#anothernewperson.guest by system and assume }", - "{ grant role hs_office_person#anothernewperson.guest to role hs_office_person#anothernewperson.tenant by system and assume }", - "{ grant role hs_office_person#anothernewperson.owner to user selfregistered-user-drew@hostsharing.org by global#global.admin and assume }" + "{ grant perm:hs_office_person#anothernewperson:INSERT>hs_office_relation to role:hs_office_person#anothernewperson:ADMIN by system and assume }", + + "{ grant role:hs_office_person#anothernewperson:OWNER to user:selfregistered-user-drew@hostsharing.org by hs_office_person#anothernewperson:OWNER and assume }", + "{ grant role:hs_office_person#anothernewperson:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant perm:hs_office_person#anothernewperson:UPDATE to role:hs_office_person#anothernewperson:ADMIN by system and assume }", + "{ grant perm:hs_office_person#anothernewperson:DELETE to role:hs_office_person#anothernewperson:OWNER by system and assume }", + "{ grant role:hs_office_person#anothernewperson:ADMIN to role:hs_office_person#anothernewperson:OWNER by system and assume }", + + "{ grant perm:hs_office_person#anothernewperson:SELECT to role:hs_office_person#anothernewperson:REFERRER by system and assume }", + "{ grant role:hs_office_person#anothernewperson:REFERRER to role:hs_office_person#anothernewperson:ADMIN by system and assume }" )); } private void assertThatPersonIsPersisted(final HsOfficePersonEntity saved) { final var found = personRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -165,7 +163,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { } @Nested - class FindByLabelLike { + class FindByCaptionLike { @Test public void globalAdmin_withoutAssumedRole_canViewAllPersons() { @@ -240,8 +238,8 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { public void deletingAPersonAlsoDeletesRelatedRolesAndGrants() { // given context("selfregistered-user-drew@hostsharing.org", null); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); final var givenPerson = givenSomeTemporaryPerson("selfregistered-user-drew@hostsharing.org"); // when @@ -253,8 +251,8 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from(initialRoleNames)); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from(initialGrantNames)); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from(initialRoleNames)); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.from(initialGrantNames)); } } @@ -262,9 +260,8 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'tradename', targetdelta->>'lastname' + from tx_journal_v where targettable = 'hs_office_person'; """); @@ -273,18 +270,10 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating person test-data First GmbH, hs_office_person, INSERT]", - "[creating person test-data Second e.K., Sandra, Miller, hs_office_person, INSERT]"); - } - - @AfterEach - void cleanup() { - context("superuser-alex@hostsharing.net", null); - final var result = personRepo.findPersonByOptionalNameLike("some temporary person"); - result.forEach(tempPerson -> { - System.out.println("DELETING temporary person: " + tempPerson.toShortString()); - personRepo.deleteByUuid(tempPerson.getUuid()); - }); + "[creating person test-data, hs_office_person, INSERT, Hostsharing eG, null]", + "[creating person test-data, hs_office_person, INSERT, First GmbH, null]", + "[creating person test-data, hs_office_person, INSERT, Second e.K., null]", + "[creating person test-data, hs_office_person, INSERT, Third OHG, null]"); } private HsOfficePersonEntity givenSomeTemporaryPerson( @@ -292,7 +281,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { Supplier entitySupplier) { return jpaAttempt.transacted(() -> { context(createdByUser); - return personRepo.save(entitySupplier.get()); + return toCleanup(personRepo.save(entitySupplier.get())); }).assumeSuccessful().returnedValue(); } @@ -301,15 +290,15 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { hsOfficePerson("some temporary person #" + RandomStringUtils.random(12))); } - void exactlyThesePersonsAreReturned(final List actualResult, final String... personLabels) { + void exactlyThesePersonsAreReturned(final List actualResult, final String... personCaptions) { assertThat(actualResult) .extracting(HsOfficePersonEntity::getTradeName) - .containsExactlyInAnyOrder(personLabels); + .containsExactlyInAnyOrder(personCaptions); } - void allThesePersonsAreReturned(final List actualResult, final String... personLabels) { + void allThesePersonsAreReturned(final List actualResult, final String... personCaptions) { assertThat(actualResult) .extracting(hsOfficePersonEntity -> hsOfficePersonEntity.toShortString()) - .contains(personLabels); + .contains(personCaptions); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java similarity index 51% rename from src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java rename to src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java index 6105a49e..265a65e3 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java @@ -1,16 +1,15 @@ -package net.hostsharing.hsadminng.hs.office.relationship; +package net.hostsharing.hsadminng.hs.office.relation; import io.restassured.RestAssured; import io.restassured.http.ContentType; -import net.hostsharing.test.Accepts; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipTypeResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationTypeResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.json.JSONException; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,12 +17,10 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.transaction.annotation.Transactional; -import java.util.HashSet; -import java.util.Set; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; @@ -33,8 +30,9 @@ import static org.hamcrest.Matchers.startsWith; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeRelationshipControllerAcceptanceTest { +class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithCleanup { + public static final UUID GIVEN_NON_EXISTING_HOLDER_PERSON_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); @LocalServerPort private Integer port; @@ -45,101 +43,89 @@ class HsOfficeRelationshipControllerAcceptanceTest { Context contextMock; @Autowired - HsOfficeRelationshipRepository relationshipRepo; + HsOfficeRelationRealRepository relationrealRepo; @Autowired HsOfficePersonRepository personRepo; @Autowired - HsOfficeContactRepository contactRepo; + HsOfficeContactRealRepository contactrealRepo; @Autowired JpaAttempt jpaAttempt; - Set tempRelationshipUuids = new HashSet<>(); - @Nested - @Accepts({ "Relationship:F(Find)" }) - class ListRelationships { + class ListRelations { @Test - void globalAdmin_withoutAssumedRoles_canViewAllRelationshipsOfGivenPersonAndType_ifNoCriteriaGiven() throws JSONException { + void globalAdmin_withoutAssumedRoles_canViewAllRelationsOfGivenPersonAndType_ifNoCriteriaGiven() throws JSONException { // given context.define("superuser-alex@hostsharing.net"); - final var givenPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); + final var givenPerson = personRepo.findPersonByOptionalNameLike("Hostsharing eG").get(0); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .port(port) .when() - .get("http://localhost/api/hs/office/relationships?personUuid=%s&relationshipType=%s" - .formatted(givenPerson.getUuid(), HsOfficeRelationshipTypeResource.REPRESENTATIVE)) + .get("http://localhost/api/hs/office/relations?personUuid=%s&relationType=%s" + .formatted(givenPerson.getUuid(), HsOfficeRelationTypeResource.PARTNER)) .then().log().all().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" [ - { - "relAnchor": { - "personType": "INCORPORATED_FIRM", - "tradeName": "Third OHG" - }, - "relHolder": { - "personType": "NATURAL_PERSON", - "givenName": "Peter", - "familyName": "Smith" - }, - "relType": "REPRESENTATIVE", - "contact": { "label": "third contact" } - }, - { - "relAnchor": { - "personType": "LEGAL_PERSON", - "tradeName": "Second e.K.", - "givenName": "Miller", - "familyName": "Sandra" - }, - "relHolder": { - "personType": "NATURAL_PERSON", - "givenName": "Peter", - "familyName": "Smith" - }, - "relType": "REPRESENTATIVE", - "contact": { "label": "second contact" } - }, - { - "relAnchor": { - "personType": "LEGAL_PERSON", - "tradeName": "First GmbH" - }, - "relHolder": { - "personType": "NATURAL_PERSON", - "tradeName": null, - "givenName": "Peter", - "familyName": "Smith" - }, - "relType": "REPRESENTATIVE", - "contact": { "label": "first contact" } - } - ] + { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG" }, + "holder": { "personType": "LEGAL_PERSON", "tradeName": "First GmbH" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "first contact" } + }, + { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG" }, + "holder": { "personType": "LEGAL_PERSON", "tradeName": "Fourth eG" }, + "type": "PARTNER", + "contact": { "caption": "fourth contact" } + }, + { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG" }, + "holder": { "personType": "LEGAL_PERSON", "tradeName": "Second e.K.", "givenName": "Peter", "familyName": "Smith" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "second contact" } + }, + { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG" }, + "holder": { "personType": "NATURAL_PERSON", "givenName": "Peter", "familyName": "Smith" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "sixth contact" } + }, + { + "anchor": { "personType": "LEGAL_PERSON", "tradeName": "Hostsharing eG" }, + "holder": { "personType": "INCORPORATED_FIRM", "tradeName": "Third OHG" }, + "type": "PARTNER", + "mark": null, + "contact": { "caption": "third contact" } + } + ] """)); // @formatter:on } } @Nested - @Accepts({ "Relationship:C(Create)" }) - class AddRelationship { + class AddRelation { @Test - void globalAdmin_withoutAssumedRole_canAddRelationship() { + void globalAdmin_withoutAssumedRole_canAddRelation() { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("second").get(0); final var location = RestAssured // @formatter:off .given() @@ -147,43 +133,46 @@ class HsOfficeRelationshipControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "relType": "%s", - "relAnchorUuid": "%s", - "relHolderUuid": "%s", + "type": "%s", + "mark": "%s", + "anchorUuid": "%s", + "holderUuid": "%s", "contactUuid": "%s" } """.formatted( - HsOfficeRelationshipTypeResource.ACCOUNTING, + HsOfficeRelationTypeResource.SUBSCRIBER, + "operations-discuss", givenAnchorPerson.getUuid(), givenHolderPerson.getUuid(), givenContact.getUuid())) .port(port) .when() - .post("http://localhost/api/hs/office/relationships") + .post("http://localhost/api/hs/office/relations") .then().log().all().assertThat() .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("relType", is("ACCOUNTING")) - .body("relAnchor.tradeName", is("Third OHG")) - .body("relHolder.givenName", is("Paul")) - .body("contact.label", is("forth contact")) + .body("type", is("SUBSCRIBER")) + .body("mark", is("operations-discuss")) + .body("anchor.tradeName", is("Third OHG")) + .body("holder.givenName", is("Paul")) + .body("contact.caption", is("second contact")) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on - // finally, the new relationship can be accessed under the generated UUID - final var newUserUuid = toCleanup(UUID.fromString( + // finally, the new relation can be accessed under the generated UUID + final var newUserUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString( location.substring(location.lastIndexOf('/') + 1))); assertThat(newUserUuid).isNotNull(); } @Test - void globalAdmin_canNotAddRelationship_ifAnchorPersonDoesNotExist() { + void globalAdmin_canNotAddRelation_ifAnchorPersonDoesNotExist() { context.define("superuser-alex@hostsharing.net"); - final var givenAnchorPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenAnchorPersonUuid = GIVEN_NON_EXISTING_HOLDER_PERSON_UUID; final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var location = RestAssured // @formatter:off .given() @@ -191,32 +180,31 @@ class HsOfficeRelationshipControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "relType": "%s", - "relAnchorUuid": "%s", - "relHolderUuid": "%s", + "type": "%s", + "anchorUuid": "%s", + "holderUuid": "%s", "contactUuid": "%s" } """.formatted( - HsOfficeRelationshipTypeResource.ACCOUNTING, + HsOfficeRelationTypeResource.DEBITOR, givenAnchorPersonUuid, givenHolderPerson.getUuid(), givenContact.getUuid())) .port(port) .when() - .post("http://localhost/api/hs/office/relationships") + .post("http://localhost/api/hs/office/relations") .then().log().all().assertThat() .statusCode(404) - .body("message", is("cannot find relAnchorUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [404] cannot find Person by anchorUuid: " + GIVEN_NON_EXISTING_HOLDER_PERSON_UUID)); // @formatter:on } @Test - void globalAdmin_canNotAddRelationship_ifHolderPersonDoesNotExist() { + void globalAdmin_canNotAddRelation_ifHolderPersonDoesNotExist() { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); - final var givenHolderPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var location = RestAssured // @formatter:off .given() @@ -224,32 +212,32 @@ class HsOfficeRelationshipControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "relType": "%s", - "relAnchorUuid": "%s", - "relHolderUuid": "%s", + "type": "%s", + "anchorUuid": "%s", + "holderUuid": "%s", "contactUuid": "%s" } """.formatted( - HsOfficeRelationshipTypeResource.ACCOUNTING, + HsOfficeRelationTypeResource.DEBITOR, givenAnchorPerson.getUuid(), - givenHolderPersonUuid, + GIVEN_NON_EXISTING_HOLDER_PERSON_UUID, givenContact.getUuid())) .port(port) .when() - .post("http://localhost/api/hs/office/relationships") + .post("http://localhost/api/hs/office/relations") .then().log().all().assertThat() .statusCode(404) - .body("message", is("cannot find relHolderUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [404] cannot find Person by holderUuid: " + GIVEN_NON_EXISTING_HOLDER_PERSON_UUID)); // @formatter:on } @Test - void globalAdmin_canNotAddRelationship_ifContactDoesNotExist() { + void globalAdmin_canNotAddRelation_ifContactDoesNotExist() { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0); - final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenContactUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); final var location = RestAssured // @formatter:off .given() @@ -257,121 +245,117 @@ class HsOfficeRelationshipControllerAcceptanceTest { .contentType(ContentType.JSON) .body(""" { - "relType": "%s", - "relAnchorUuid": "%s", - "relHolderUuid": "%s", + "type": "%s", + "anchorUuid": "%s", + "holderUuid": "%s", "contactUuid": "%s" } - """.formatted( - HsOfficeRelationshipTypeResource.ACCOUNTING, + """.formatted( + HsOfficeRelationTypeResource.DEBITOR, givenAnchorPerson.getUuid(), givenHolderPerson.getUuid(), givenContactUuid)) .port(port) .when() - .post("http://localhost/api/hs/office/relationships") + .post("http://localhost/api/hs/office/relations") .then().log().all().assertThat() .statusCode(404) - .body("message", is("cannot find contactUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [404] cannot find Contact by contactUuid: 00000000-0000-0000-0000-000000000000")); // @formatter:on } } @Nested - @Accepts({ "Relationship:R(Read)" }) - class GetRelationship { + class GetRelation { @Test - void globalAdmin_withoutAssumedRole_canGetArbitraryRelationship() { + void globalAdmin_withoutAssumedRole_canGetArbitraryRelation() { context.define("superuser-alex@hostsharing.net"); - final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid(); + final UUID givenRelationUuid = findRelation("First", "Firby").getUuid(); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .port(port) .when() - .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid) + .get("http://localhost/api/hs/office/relations/" + givenRelationUuid) .then().log().body().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" { - "relAnchor": { "tradeName": "First GmbH" }, - "relHolder": { "familyName": "Smith" }, - "contact": { "label": "first contact" } + "anchor": { "tradeName": "First GmbH" }, + "holder": { "familyName": "Firby" }, + "contact": { "caption": "first contact" } } """)); // @formatter:on } @Test - @Accepts({ "Relationship:X(Access Control)" }) - void normalUser_canNotGetUnrelatedRelationship() { + void normalUser_canNotGetUnrelatedRelation() { context.define("superuser-alex@hostsharing.net"); - final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid(); + final UUID givenRelationUuid = findRelation("First", "Firby").getUuid(); RestAssured // @formatter:off .given() .header("current-user", "selfregistered-user-drew@hostsharing.org") .port(port) .when() - .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid) + .get("http://localhost/api/hs/office/relations/" + givenRelationUuid) .then().log().body().assertThat() .statusCode(404); // @formatter:on } @Test - @Accepts({ "Relationship:X(Access Control)" }) - void contactAdminUser_canGetRelatedRelationship() { + void contactAdminUser_canGetRelatedRelation() { context.define("superuser-alex@hostsharing.net"); - final var givenRelationship = findRelationship("First", "Smith"); - assertThat(givenRelationship.getContact().getLabel()).isEqualTo("first contact"); + final var givenRelation = findRelation("First", "Firby"); + assertThat(givenRelation.getContact().getCaption()).isEqualTo("first contact"); RestAssured // @formatter:off .given() .header("current-user", "contact-admin@firstcontact.example.com") .port(port) .when() - .get("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .get("http://localhost/api/hs/office/relations/" + givenRelation.getUuid()) .then().log().body().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" { - "relAnchor": { "tradeName": "First GmbH" }, - "relHolder": { "familyName": "Smith" }, - "contact": { "label": "first contact" } + "anchor": { "tradeName": "First GmbH" }, + "holder": { "familyName": "Firby" }, + "contact": { "caption": "first contact" } } """)); // @formatter:on } } - private HsOfficeRelationshipEntity findRelationship( + private HsOfficeRelation findRelation( final String anchorPersonName, final String holderPersoneName) { final var anchorPersonUuid = personRepo.findPersonByOptionalNameLike(anchorPersonName).get(0).getUuid(); final var holderPersonUuid = personRepo.findPersonByOptionalNameLike(holderPersoneName).get(0).getUuid(); - final var givenRelationship = relationshipRepo - .findRelationshipRelatedToPersonUuid(anchorPersonUuid) + final var givenRelation = relationrealRepo + .findRelationRelatedToPersonUuid(anchorPersonUuid) .stream() - .filter(r -> r.getRelHolder().getUuid().equals(holderPersonUuid)) + .filter(r -> r.getHolder().getUuid().equals(holderPersonUuid)) .findFirst().orElseThrow(); - return givenRelationship; + return givenRelation; } @Nested - @Accepts({ "Relationship:U(Update)" }) - class PatchRelationship { + class PatchRelation { @Test - void globalAdmin_withoutAssumedRole_canPatchContactOfArbitraryRelationship() { + void globalAdmin_withoutAssumedRole_canPatchContactOfArbitraryRelation() { context.define("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler(); - assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + final var givenRelation = givenSomeTemporaryRelationBessler(); + assertThat(givenRelation.getContact().getCaption()).isEqualTo("seventh contact"); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) @@ -382,128 +366,107 @@ class HsOfficeRelationshipControllerAcceptanceTest { """.formatted(givenContact.getUuid())) .port(port) .when() - .patch("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .patch("http://localhost/api/hs/office/relations/" + givenRelation.getUuid()) .then().log().all().assertThat() .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("relType", is("REPRESENTATIVE")) - .body("relAnchor.tradeName", is("Erben Bessler")) - .body("relHolder.familyName", is("Winkler")) - .body("contact.label", is("forth contact")); + .body("type", is("REPRESENTATIVE")) + .body("anchor.tradeName", is("Erben Bessler")) + .body("holder.familyName", is("Winkler")) + .body("contact.caption", is("fourth contact")); // @formatter:on - // finally, the relationship is actually updated + // finally, the relation is actually updated context.define("superuser-alex@hostsharing.net"); - assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent().get() + assertThat(relationrealRepo.findByUuid(givenRelation.getUuid())).isPresent().get() .matches(rel -> { - assertThat(rel.getRelAnchor().getTradeName()).contains("Bessler"); - assertThat(rel.getRelHolder().getFamilyName()).contains("Winkler"); - assertThat(rel.getContact().getLabel()).isEqualTo("forth contact"); - assertThat(rel.getRelType()).isEqualTo(HsOfficeRelationshipType.REPRESENTATIVE); + assertThat(rel.getAnchor().getTradeName()).contains("Bessler"); + assertThat(rel.getHolder().getFamilyName()).contains("Winkler"); + assertThat(rel.getContact().getCaption()).isEqualTo("fourth contact"); + assertThat(rel.getType()).isEqualTo(HsOfficeRelationType.REPRESENTATIVE); return true; }); } } @Nested - @Accepts({ "Relationship:D(Delete)" }) - class DeleteRelationship { + class DeleteRelation { @Test - void globalAdmin_withoutAssumedRole_canDeleteArbitraryRelationship() { + void globalAdmin_withoutAssumedRole_canDeleteArbitraryRelation() { context.define("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler(); + final var givenRelation = givenSomeTemporaryRelationBessler(); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .port(port) .when() - .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .delete("http://localhost/api/hs/office/relations/" + givenRelation.getUuid()) .then().log().body().assertThat() .statusCode(204); // @formatter:on - // then the given relationship is gone - assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isEmpty(); + // then the given relation is gone + assertThat(relationrealRepo.findByUuid(givenRelation.getUuid())).isEmpty(); } @Test - @Accepts({ "Relationship:X(Access Control)" }) - void contactAdminUser_canNotDeleteRelatedRelationship() { + void contactAdminUser_canNotDeleteRelatedRelation() { context.define("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler(); - assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); + final var givenRelation = givenSomeTemporaryRelationBessler(); + assertThat(givenRelation.getContact().getCaption()).isEqualTo("seventh contact"); RestAssured // @formatter:off .given() .header("current-user", "contact-admin@seventhcontact.example.com") .port(port) .when() - .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .delete("http://localhost/api/hs/office/relations/" + givenRelation.getUuid()) .then().log().body().assertThat() .statusCode(403); // @formatter:on - // then the given relationship is still there - assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty(); + // then the given relation is still there + assertThat(relationrealRepo.findByUuid(givenRelation.getUuid())).isNotEmpty(); } @Test - @Accepts({ "Relationship:X(Access Control)" }) - void normalUser_canNotDeleteUnrelatedRelationship() { + void normalUser_canNotDeleteUnrelatedRelation() { context.define("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler(); - assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact"); + final var givenRelation = givenSomeTemporaryRelationBessler(); + assertThat(givenRelation.getContact().getCaption()).isEqualTo("seventh contact"); RestAssured // @formatter:off .given() .header("current-user", "selfregistered-user-drew@hostsharing.org") .port(port) .when() - .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid()) + .delete("http://localhost/api/hs/office/relations/" + givenRelation.getUuid()) .then().log().body().assertThat() .statusCode(404); // @formatter:on - // then the given relationship is still there - assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty(); + // then the given relation is still there + assertThat(relationrealRepo.findByUuid(givenRelation.getUuid())).isNotEmpty(); } } - private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler() { + private HsOfficeRelation givenSomeTemporaryRelationBessler() { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Winkler").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("seventh contact").get(0); - final var newRelationship = HsOfficeRelationshipEntity.builder() - .uuid(UUID.randomUUID()) - .relType(HsOfficeRelationshipType.REPRESENTATIVE) - .relAnchor(givenAnchorPerson) - .relHolder(givenHolderPerson) + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("seventh contact").get(0); + final var newRelation = HsOfficeRelationRealEntity.builder() + .type(HsOfficeRelationType.REPRESENTATIVE) + .anchor(givenAnchorPerson) + .holder(givenHolderPerson) .contact(givenContact) .build(); - toCleanup(newRelationship.getUuid()); + assertThat(toCleanup(relationrealRepo.save(newRelation))).isEqualTo(newRelation); - return relationshipRepo.save(newRelationship); + return newRelation; }).assertSuccessful().returnedValue(); } - private UUID toCleanup(final UUID tempRelationshipUuid) { - tempRelationshipUuids.add(tempRelationshipUuid); - return tempRelationshipUuid; - } - - @AfterEach - void cleanup() { - tempRelationshipUuids.forEach(uuid -> { - jpaAttempt.transacted(() -> { - context.define("superuser-alex@hostsharing.net", null); - System.out.println("DELETING temporary relationship: " + uuid); - final var count = relationshipRepo.deleteByUuid(uuid); - System.out.println("DELETED temporary relationship: " + uuid + (count > 0 ? " successful" : " failed")); - }); - }); - } - } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationPatcherUnitTest.java similarity index 52% rename from src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationPatcherUnitTest.java index 1c12a629..2fc9b95e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationPatcherUnitTest.java @@ -1,9 +1,9 @@ -package net.hostsharing.hsadminng.hs.office.relationship; +package net.hostsharing.hsadminng.hs.office.relation; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,12 +21,12 @@ import static org.mockito.Mockito.lenient; @TestInstance(PER_CLASS) @ExtendWith(MockitoExtension.class) -class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase< - HsOfficeRelationshipPatchResource, - HsOfficeRelationshipEntity +class HsOfficeRelationPatcherUnitTest extends PatchUnitTestBase< + HsOfficeRelationPatchResource, + HsOfficeRelation > { - static final UUID INITIAL_RELATIONSHIP_UUID = UUID.randomUUID(); + static final UUID INITIAL_RELATION_UUID = UUID.randomUUID(); static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); @Mock @@ -34,8 +34,8 @@ class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase< @BeforeEach void initMocks() { - lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> - HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeContactRealEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactRealEntity.builder().uuid(invocation.getArgument(1)).build()); } final HsOfficePersonEntity givenInitialAnchorPerson = HsOfficePersonEntity.builder() @@ -44,29 +44,29 @@ class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase< final HsOfficePersonEntity givenInitialHolderPerson = HsOfficePersonEntity.builder() .uuid(UUID.randomUUID()) .build(); - final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() + final HsOfficeContactRealEntity givenInitialContact = HsOfficeContactRealEntity.builder() .uuid(UUID.randomUUID()) .build(); @Override - protected HsOfficeRelationshipEntity newInitialEntity() { - final var entity = new HsOfficeRelationshipEntity(); - entity.setUuid(INITIAL_RELATIONSHIP_UUID); - entity.setRelType(HsOfficeRelationshipType.REPRESENTATIVE); - entity.setRelAnchor(givenInitialAnchorPerson); - entity.setRelHolder(givenInitialHolderPerson); + protected HsOfficeRelation newInitialEntity() { + final var entity = new HsOfficeRelationRbacEntity(); + entity.setUuid(INITIAL_RELATION_UUID); + entity.setType(HsOfficeRelationType.REPRESENTATIVE); + entity.setAnchor(givenInitialAnchorPerson); + entity.setHolder(givenInitialHolderPerson); entity.setContact(givenInitialContact); return entity; } @Override - protected HsOfficeRelationshipPatchResource newPatchResource() { - return new HsOfficeRelationshipPatchResource(); + protected HsOfficeRelationPatchResource newPatchResource() { + return new HsOfficeRelationPatchResource(); } @Override - protected HsOfficeRelationshipEntityPatcher createPatcher(final HsOfficeRelationshipEntity relationship) { - return new HsOfficeRelationshipEntityPatcher(em, relationship); + protected HsOfficeRelationEntityPatcher createPatcher(final HsOfficeRelation relation) { + return new HsOfficeRelationEntityPatcher(em, relation); } @Override @@ -74,17 +74,15 @@ class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase< return Stream.of( new JsonNullableProperty<>( "contact", - HsOfficeRelationshipPatchResource::setContactUuid, + HsOfficeRelationPatchResource::setContactUuid, PATCHED_CONTACT_UUID, - HsOfficeRelationshipEntity::setContact, + HsOfficeRelation::setContact, newContact(PATCHED_CONTACT_UUID)) .notNullable() ); } - static HsOfficeContactEntity newContact(final UUID uuid) { - final var newContact = new HsOfficeContactEntity(); - newContact.setUuid(uuid); - return newContact; + static HsOfficeContactRealEntity newContact(final UUID uuid) { + return HsOfficeContactRealEntity.builder().uuid(uuid).build(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java new file mode 100644 index 00000000..b9ccb589 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java @@ -0,0 +1,442 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +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.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.JpaSystemException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; + +import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON; +import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.UNINCORPORATED_FIRM; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import( { Context.class, JpaAttempt.class }) +class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + HsOfficeRelationRbacRepository relationRbacRepo; + + @Autowired + HsOfficePersonRepository personRepo; + + @Autowired + HsOfficeContactRealRepository contactrealRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @PersistenceContext + EntityManager em; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + @Nested + class CreateRelation { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewRelation() { + // given + context("superuser-alex@hostsharing.net"); + final var count = relationRbacRepo.count(); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").stream() + .filter(p -> p.getPersonType() == UNINCORPORATED_FIRM) + .findFirst().orElseThrow(); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").stream() + .filter(p -> p.getPersonType() == NATURAL_PERSON) + .findFirst().orElseThrow(); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth contact").stream() + .findFirst().orElseThrow(); + + // when + final var result = attempt(em, () -> { + final var newRelation = HsOfficeRelationRbacEntity.builder() + .anchor(givenAnchorPerson) + .holder(givenHolderPerson) + .type(HsOfficeRelationType.SUBSCRIBER) + .mark("operations-announce") + .contact(givenContact) + .build(); + return toCleanup(relationRbacRepo.save(newRelation)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeRelation::getUuid).isNotNull(); + assertThatRelationIsPersisted(result.returnedValue()); + assertThat(relationRbacRepo.count()).isEqualTo(count + 1); + final var stored = relationRbacRepo.findByUuid(result.returnedValue().getUuid()); + assertThat(stored).isNotEmpty().map(HsOfficeRelation::toString).get() + .isEqualTo("rel(anchor='UF Erben Bessler', type='SUBSCRIBER', mark='operations-announce', holder='NP Winkler, Paul', contact='fourth contact')"); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); + + // when + attempt(em, () -> { + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").stream() + .filter(p -> p.getPersonType() == UNINCORPORATED_FIRM) + .findFirst().orElseThrow(); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Bert").stream() + .filter(p -> p.getPersonType() == NATURAL_PERSON) + .findFirst().orElseThrow(); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth contact").stream() + .findFirst().orElseThrow(); + final var newRelation = HsOfficeRelationRbacEntity.builder() + .anchor(givenAnchorPerson) + .holder(givenHolderPerson) + .type(HsOfficeRelationType.REPRESENTATIVE) + .contact(givenContact) + .build(); + return toCleanup(relationRbacRepo.save(newRelation)); + }); + + // then + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER", + "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:ADMIN", + "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:AGENT", + "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromFormatted( + initialGrantNames, + + "{ grant perm:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:DELETE to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER to role:global#global:ADMIN by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER to user:superuser-alex@hostsharing.net by hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER and assume }", + + "{ grant perm:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:UPDATE to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:ADMIN by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:ADMIN to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:OWNER to role:hs_office_person#BesslerBert:ADMIN by system and assume }", + "{ grant role:hs_office_person#ErbenBesslerMelBessler:OWNER to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:ADMIN by system and assume }", + + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:AGENT to role:hs_office_person#ErbenBesslerMelBessler:ADMIN by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:AGENT to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:ADMIN by system and assume }", + + "{ grant perm:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:SELECT to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT by system and assume }", + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:AGENT by system and assume }", + "{ grant role:hs_office_person#BesslerBert:REFERRER to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT by system and assume }", + "{ grant role:hs_office_person#ErbenBesslerMelBessler:REFERRER to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT by system and assume }", + "{ grant role:hs_office_contact#fourthcontact:REFERRER to role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT by system and assume }", + + // REPRESENTATIVE holder person -> (represented) anchor person + "{ grant role:hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerBert:TENANT to role:hs_office_contact#fourthcontact:ADMIN by system and assume }", + null) + ); + } + + private void assertThatRelationIsPersisted(final HsOfficeRelation saved) { + final var found = relationRbacRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); + } + } + + @Nested + class FindAllRelations { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllRelationsOfArbitraryPerson() { + // given + context("superuser-alex@hostsharing.net"); + final var person = personRepo.findPersonByOptionalNameLike("Smith").stream() + .filter(p -> p.getPersonType() == NATURAL_PERSON) + .findFirst().orElseThrow(); + + // when + final var result = relationRbacRepo.findRelationRelatedToPersonUuid(person.getUuid()); + + // then + allTheseRelationsAreReturned( + result, + "rel(anchor='LP Hostsharing eG', type='PARTNER', holder='NP Smith, Peter', contact='sixth contact')", + "rel(anchor='LP Second e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')", + "rel(anchor='IF Third OHG', type='SUBSCRIBER', mark='members-announce', holder='NP Smith, Peter', contact='third contact')"); + } + + @Test + public void normalUser_canViewRelationsOfOwnedPersons() { + // given: + context("person-SmithPeter@example.com"); + final var person = personRepo.findPersonByOptionalNameLike("Smith").stream() + .filter(p -> p.getPersonType() == NATURAL_PERSON) + .findFirst().orElseThrow(); + + // when: + final var result = relationRbacRepo.findRelationRelatedToPersonUuid(person.getUuid()); + + // then: + exactlyTheseRelationsAreReturned( + result, + "rel(anchor='LP Second e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')", + "rel(anchor='IF Third OHG', type='SUBSCRIBER', mark='members-announce', holder='NP Smith, Peter', contact='third contact')", + "rel(anchor='LP Hostsharing eG', type='PARTNER', holder='NP Smith, Peter', contact='sixth contact')", + "rel(anchor='NP Smith, Peter', type='DEBITOR', holder='NP Smith, Peter', contact='third contact')"); + } + } + + @Nested + class UpdateRelation { + + @Test + public void hostsharingAdmin_withoutAssumedRole_canUpdateContactOfArbitraryRelation() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Bert", "fifth contact"); + assertThatRelationActuallyInDatabase(givenRelation); + assertThatRelationIsVisibleForUserWithRole( + givenRelation, + "hs_office_person#ErbenBesslerMelBessler:ADMIN"); + context("superuser-alex@hostsharing.net"); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("sixth contact").stream().findFirst().orElseThrow(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + givenRelation.setContact(givenContact); + return toCleanup(relationRbacRepo.save(givenRelation).load()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue().getContact().getCaption()).isEqualTo("sixth contact"); + assertThatRelationIsVisibleForUserWithRole( + result.returnedValue(), + "global#global:ADMIN"); + assertThatRelationIsVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#sixthcontact:ADMIN"); + + assertThatRelationIsNotVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#fifthcontact:ADMIN"); + + relationRbacRepo.deleteByUuid(givenRelation.getUuid()); + } + + @Test + public void relationAgent_canSelectButNotUpdateRelatedRelation() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Anita", "eighth"); + assertThatRelationIsVisibleForUserWithRole( + givenRelation, + "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerAnita:AGENT"); + assertThatRelationActuallyInDatabase(givenRelation); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_relation#ErbenBesslerMelBessler-with-REPRESENTATIVE-BesslerAnita:AGENT"); + givenRelation.setContact(null); + return relationRbacRepo.save(givenRelation); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_relation uuid"); + } + + @Test + public void contactAdmin_canNotUpdateRelatedRelation() { + // given + context("superuser-alex@hostsharing.net"); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Anita", "ninth"); + assertThatRelationIsVisibleForUserWithRole( + givenRelation, + "hs_office_contact#ninthcontact:ADMIN"); + assertThatRelationActuallyInDatabase(givenRelation); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact:ADMIN"); + givenRelation.setContact(null); // TODO + return relationRbacRepo.save(givenRelation); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_relation uuid"); + } + + private void assertThatRelationActuallyInDatabase(final HsOfficeRelation saved) { + final var found = relationRbacRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get() + .isNotSameAs(saved) + .extracting(HsOfficeRelation::toString) + .isEqualTo(saved.toString()); + } + + private void assertThatRelationIsVisibleForUserWithRole( + final HsOfficeRelation entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + assertThatRelationActuallyInDatabase(entity); + }).assertSuccessful(); + } + + private void assertThatRelationIsNotVisibleForUserWithRole( + final HsOfficeRelation entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + final var found = relationRbacRepo.findByUuid(entity.getUuid()); + assertThat(found).isEmpty(); + }).assertSuccessful(); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyRelation() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Anita", "tenth"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + relationRbacRepo.deleteByUuid(givenRelation.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return relationRbacRepo.findByUuid(givenRelation.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void contactUser_canViewButNotDeleteTheirRelatedRelation() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Anita", "eleventh"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("contact-admin@eleventhcontact.example.com"); + assertThat(relationRbacRepo.findByUuid(givenRelation.getUuid())).isPresent(); + relationRbacRepo.deleteByUuid(givenRelation.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " not allowed to delete hs_office_relation"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return relationRbacRepo.findByUuid(givenRelation.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingARelationAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenRelation = givenSomeTemporaryRelationBessler( + "Anita", "twelfth"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return relationRbacRepo.deleteByUuid(givenRelation.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select currentTask, targetTable, targetOp, targetdelta->>'mark' + from tx_journal_v + where targettable = 'hs_office_relation'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating relation test-data, hs_office_relation, INSERT, members-announce]"); + } + + private HsOfficeRelationRbacEntity givenSomeTemporaryRelationBessler(final String holderPerson, final String contact) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); + final var givenHolderPerson = personRepo.findPersonByOptionalNameLike(holderPerson).get(0); + final var givenContact = contactrealRepo.findContactByOptionalCaptionLike(contact).get(0); + final var newRelation = HsOfficeRelationRbacEntity.builder() + .type(HsOfficeRelationType.REPRESENTATIVE) + .anchor(givenAnchorPerson) + .holder(givenHolderPerson) + .contact(givenContact) + .build(); + + return toCleanup(relationRbacRepo.save(newRelation)); + }).assertSuccessful().returnedValue(); + } + + void exactlyTheseRelationsAreReturned( + final List actualResult, + final String... relationNames) { + assertThat(actualResult) + .extracting(HsOfficeRelation::toString) + .containsExactlyInAnyOrder(relationNames); + } + + void allTheseRelationsAreReturned( + final List actualResult, + final String... relationNames) { + assertThat(actualResult) + .extracting(HsOfficeRelation::toString) + .contains(relationNames); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationUnitTest.java new file mode 100644 index 00000000..a422a8b6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationUnitTest.java @@ -0,0 +1,43 @@ +package net.hostsharing.hsadminng.hs.office.relation; + +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeRelationUnitTest { + + private HsOfficePersonEntity anchor = HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.LEGAL_PERSON) + .tradeName("some trade name") + .build(); + private HsOfficePersonEntity holder = HsOfficePersonEntity.builder() + .personType(HsOfficePersonType.NATURAL_PERSON) + .familyName("Meier") + .givenName("Mellie") + .build(); + + @Test + void toStringReturnsAllProperties() { + final var given = HsOfficeRelationRbacEntity.builder() + .type(HsOfficeRelationType.SUBSCRIBER) + .mark("members-announce") + .anchor(anchor) + .holder(holder) + .build(); + + assertThat(given.toString()).isEqualTo("rel(anchor='LP some trade name', type='SUBSCRIBER', mark='members-announce', holder='NP Meier, Mellie')"); + } + + @Test + void toShortString() { + final var given = HsOfficeRelationRbacEntity.builder() + .type(HsOfficeRelationType.REPRESENTATIVE) + .anchor(anchor) + .holder(holder) + .build(); + + assertThat(given.toShortString()).isEqualTo("rel(anchor='LP some trade name', type='REPRESENTATIVE', holder='NP Meier, Mellie')"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityUnitTest.java deleted file mode 100644 index 59433fa2..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityUnitTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -class HsOfficeRelationshipEntityUnitTest { - - private HsOfficePersonEntity anchor = HsOfficePersonEntity.builder() - .personType(HsOfficePersonType.LEGAL_PERSON) - .tradeName("some trade name") - .build(); - private HsOfficePersonEntity holder = HsOfficePersonEntity.builder() - .personType(HsOfficePersonType.NATURAL_PERSON) - .familyName("Meier") - .givenName("Mellie") - .build(); - - @Test - void toStringReturnsAllProperties() { - final var given = HsOfficeRelationshipEntity.builder() - .relType(HsOfficeRelationshipType.SUBSCRIBER) - .relMark("members-announce") - .relAnchor(anchor) - .relHolder(holder) - .build(); - - assertThat(given.toString()).isEqualTo("rel(relAnchor='LP some trade name', relType='SUBSCRIBER', relMark='members-announce', relHolder='NP Meier, Mellie')"); - } - - @Test - void toShortString() { - final var given = HsOfficeRelationshipEntity.builder() - .relType(HsOfficeRelationshipType.REPRESENTATIVE) - .relAnchor(anchor) - .relHolder(holder) - .build(); - - assertThat(given.toShortString()).isEqualTo("rel(relAnchor='LP some trade name', relType='REPRESENTATIVE', relHolder='NP Meier, Mellie')"); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java deleted file mode 100644 index 5eae5b45..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java +++ /dev/null @@ -1,436 +0,0 @@ -package net.hostsharing.hsadminng.hs.office.relationship; - -import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; -import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; -import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; -import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -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.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.orm.jpa.JpaSystemException; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@Import( { Context.class, JpaAttempt.class }) -class HsOfficeRelationshipRepositoryIntegrationTest extends ContextBasedTest { - - @Autowired - HsOfficeRelationshipRepository relationshipRepo; - - @Autowired - HsOfficePersonRepository personRepo; - - @Autowired - HsOfficeContactRepository contactRepo; - - @Autowired - RawRbacRoleRepository rawRoleRepo; - - @Autowired - RawRbacGrantRepository rawGrantRepo; - - @PersistenceContext - EntityManager em; - - @Autowired - JpaAttempt jpaAttempt; - - @MockBean - HttpServletRequest request; - - Set tempRelationships = new HashSet<>(); - - @Nested - class CreateRelationship { - - @Test - public void testHostsharingAdmin_withoutAssumedRole_canCreateNewRelationship() { - // given - context("superuser-alex@hostsharing.net"); - final var count = relationshipRepo.count(); - final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0); - final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); - - // when - final var result = attempt(em, () -> { - final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder() - .relAnchor(givenAnchorPerson) - .relHolder(givenHolderPerson) - .relType(HsOfficeRelationshipType.REPRESENTATIVE) - .contact(givenContact) - .build()); - return relationshipRepo.save(newRelationship); - }); - - // then - result.assertSuccessful(); - assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeRelationshipEntity::getUuid).isNotNull(); - assertThatRelationshipIsPersisted(result.returnedValue()); - assertThat(relationshipRepo.count()).isEqualTo(count + 1); - } - - @Test - public void createsAndGrantsRoles() { - // given - context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); - - // when - attempt(em, () -> { - final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0); - final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); - final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder() - .relAnchor(givenAnchorPerson) - .relHolder(givenHolderPerson) - .relType(HsOfficeRelationshipType.REPRESENTATIVE) - .contact(givenContact) - .build()); - return relationshipRepo.save(newRelationship); - }); - - // then - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( - initialRoleNames, - "hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.admin", - "hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.owner", - "hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant")); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromFormatted( - initialGrantNames, - - "{ grant perm * on hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.owner by system and assume }", - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.owner to role global#global.admin by system and assume }", - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.owner to role hs_office_person#BesslerAnita.admin by system and assume }", - - "{ grant perm edit on hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.admin by system and assume }", - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.admin to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.owner by system and assume }", - - "{ grant perm view on hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant by system and assume }", - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant to role hs_office_contact#forthcontact.admin by system and assume }", - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant to role hs_office_person#BesslerAnita.admin by system and assume }", - - "{ grant role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.admin by system and assume }", - "{ grant role hs_office_contact#forthcontact.tenant to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant by system and assume }", - "{ grant role hs_office_person#BesslerAnita.tenant to role hs_office_relationship#BesslerAnita-with-REPRESENTATIVE-BesslerAnita.tenant by system and assume }", - null) - ); - } - - private void assertThatRelationshipIsPersisted(final HsOfficeRelationshipEntity saved) { - final var found = relationshipRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); - } - } - - @Nested - class FindAllRelationships { - - @Test - public void globalAdmin_withoutAssumedRole_canViewAllRelationshipsOfArbitraryPerson() { - // given - context("superuser-alex@hostsharing.net"); - final var person = personRepo.findPersonByOptionalNameLike("Smith").stream().findFirst().orElseThrow(); - - // when - final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid()); - - // then - allTheseRelationshipsAreReturned( - result, - "rel(relAnchor='LP First GmbH', relType='REPRESENTATIVE', relHolder='NP Smith, Peter', contact='first contact')", - "rel(relAnchor='IF Third OHG', relType='REPRESENTATIVE', relHolder='NP Smith, Peter', contact='third contact')", - "rel(relAnchor='LP Second e.K.', relType='REPRESENTATIVE', relHolder='NP Smith, Peter', contact='second contact')"); - } - - @Test - public void normalUser_canViewRelationshipsOfOwnedPersons() { - // given: - context("person-FirstGmbH@example.com"); - final var person = personRepo.findPersonByOptionalNameLike("First").stream().findFirst().orElseThrow(); - - // when: - final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid()); - - // then: - exactlyTheseRelationshipsAreReturned( - result, - "rel(relAnchor='LP First GmbH', relType='REPRESENTATIVE', relHolder='NP Smith, Peter', contact='first contact')"); - } - } - - @Nested - class UpdateRelationship { - - @Test - public void hostsharingAdmin_withoutAssumedRole_canUpdateContactOfArbitraryRelationship() { - // given - context("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "fifth contact"); - assertThatRelationshipIsVisibleForUserWithRole( - givenRelationship, - "hs_office_person#ErbenBesslerMelBessler.admin"); - assertThatRelationshipActuallyInDatabase(givenRelationship); - context("superuser-alex@hostsharing.net"); - final var givenContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0); - - // when - final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net"); - givenRelationship.setContact(givenContact); - return toCleanup(relationshipRepo.save(givenRelationship)); - }); - - // then - result.assertSuccessful(); - assertThat(result.returnedValue().getContact().getLabel()).isEqualTo("sixth contact"); - assertThatRelationshipIsVisibleForUserWithRole( - result.returnedValue(), - "global#global.admin"); - assertThatRelationshipIsVisibleForUserWithRole( - result.returnedValue(), - "hs_office_contact#sixthcontact.admin"); - - assertThatRelationshipIsNotVisibleForUserWithRole( - result.returnedValue(), - "hs_office_contact#fifthcontact.admin"); - - relationshipRepo.deleteByUuid(givenRelationship.getUuid()); - } - - @Test - public void relHolderAdmin_canNotUpdateRelatedRelationship() { - // given - context("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "eighth"); - assertThatRelationshipIsVisibleForUserWithRole( - givenRelationship, - "hs_office_person#BesslerAnita.admin"); - assertThatRelationshipActuallyInDatabase(givenRelationship); - - // when - final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_person#BesslerAnita.admin"); - givenRelationship.setContact(null); - return relationshipRepo.save(givenRelationship); - }); - - // then - result.assertExceptionWithRootCauseMessage(JpaSystemException.class, - "[403] Subject ", " is not allowed to update hs_office_relationship uuid"); - } - - @Test - public void contactAdmin_canNotUpdateRelatedRelationship() { - // given - context("superuser-alex@hostsharing.net"); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "ninth"); - assertThatRelationshipIsVisibleForUserWithRole( - givenRelationship, - "hs_office_contact#ninthcontact.admin"); - assertThatRelationshipActuallyInDatabase(givenRelationship); - - // when - final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin"); - givenRelationship.setContact(null); // TODO - return relationshipRepo.save(givenRelationship); - }); - - // then - result.assertExceptionWithRootCauseMessage(JpaSystemException.class, - "[403] Subject ", " is not allowed to update hs_office_relationship uuid"); - } - - private void assertThatRelationshipActuallyInDatabase(final HsOfficeRelationshipEntity saved) { - final var found = relationshipRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved); - } - - private void assertThatRelationshipIsVisibleForUserWithRole( - final HsOfficeRelationshipEntity entity, - final String assumedRoles) { - jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", assumedRoles); - assertThatRelationshipActuallyInDatabase(entity); - }).assertSuccessful(); - } - - private void assertThatRelationshipIsNotVisibleForUserWithRole( - final HsOfficeRelationshipEntity entity, - final String assumedRoles) { - jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", assumedRoles); - final var found = relationshipRepo.findByUuid(entity.getUuid()); - assertThat(found).isEmpty(); - }).assertSuccessful(); - } - } - - @Nested - class DeleteByUuid { - - @Test - public void globalAdmin_withoutAssumedRole_canDeleteAnyRelationship() { - // given - context("superuser-alex@hostsharing.net", null); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "tenth"); - - // when - final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net"); - relationshipRepo.deleteByUuid(givenRelationship.getUuid()); - }); - - // then - result.assertSuccessful(); - assertThat(jpaAttempt.transacted(() -> { - context("superuser-fran@hostsharing.net", null); - return relationshipRepo.findByUuid(givenRelationship.getUuid()); - }).assertSuccessful().returnedValue()).isEmpty(); - } - - @Test - public void contactUser_canViewButNotDeleteTheirRelatedRelationship() { - // given - context("superuser-alex@hostsharing.net", null); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "eleventh"); - - // when - final var result = jpaAttempt.transacted(() -> { - context("contact-admin@eleventhcontact.example.com"); - assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent(); - relationshipRepo.deleteByUuid(givenRelationship.getUuid()); - }); - - // then - result.assertExceptionWithRootCauseMessage( - JpaSystemException.class, - "[403] Subject ", " not allowed to delete hs_office_relationship"); - assertThat(jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net"); - return relationshipRepo.findByUuid(givenRelationship.getUuid()); - }).assertSuccessful().returnedValue()).isPresent(); // still there - } - - @Test - public void deletingARelationshipAlsoDeletesRelatedRolesAndGrants() { - // given - context("superuser-alex@hostsharing.net"); - final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); - final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); - final var givenRelationship = givenSomeTemporaryRelationshipBessler( - "Anita", "twelfth"); - assertThat(rawRoleRepo.findAll().size()).as("unexpected number of roles created") - .isEqualTo(initialRoleNames.length + 3); - assertThat(rawGrantRepo.findAll().size()).as("unexpected number of grants created") - .isEqualTo(initialGrantNames.length + 13); - - // when - final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net"); - return relationshipRepo.deleteByUuid(givenRelationship.getUuid()); - }); - - // then - result.assertSuccessful(); - assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); - } - } - - @Test - public void auditJournalLogIsAvailable() { - // given - final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId - where targettable = 'hs_office_relationship'; - """); - - // when - @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); - - // then - assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating relationship test-data FirstGmbH-Smith, hs_office_relationship, INSERT]", - "[creating relationship test-data Seconde.K.-Smith, hs_office_relationship, INSERT]"); - } - - private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler(final String holderPerson, final String contact) { - return jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net"); - final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0); - final var givenHolderPerson = personRepo.findPersonByOptionalNameLike(holderPerson).get(0); - final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0); - final var newRelationship = HsOfficeRelationshipEntity.builder() - .relType(HsOfficeRelationshipType.REPRESENTATIVE) - .relAnchor(givenAnchorPerson) - .relHolder(givenHolderPerson) - .contact(givenContact) - .build(); - - toCleanup(newRelationship); - - return relationshipRepo.save(newRelationship); - }).assertSuccessful().returnedValue(); - } - - private HsOfficeRelationshipEntity toCleanup(final HsOfficeRelationshipEntity tempRelationship) { - tempRelationships.add(tempRelationship); - return tempRelationship; - } - - @AfterEach - void cleanup() { - context("superuser-alex@hostsharing.net", null); - tempRelationships.forEach(tempRelationship -> { - System.out.println("DELETING temporary relationship: " + tempRelationship); - relationshipRepo.deleteByUuid(tempRelationship.getUuid()); - }); - } - - void exactlyTheseRelationshipsAreReturned( - final List actualResult, - final String... relationshipNames) { - assertThat(actualResult) - .extracting(HsOfficeRelationshipEntity::toString) - .containsExactlyInAnyOrder(relationshipNames); - } - - void allTheseRelationshipsAreReturned( - final List actualResult, - final String... relationshipNames) { - assertThat(actualResult) - .extracting(HsOfficeRelationshipEntity::toString) - .contains(relationshipNames); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java index 0cf0c887..7d7e2c3a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java @@ -1,14 +1,13 @@ package net.hostsharing.hsadminng.hs.office.sepamandate; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; -import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -24,8 +23,9 @@ import jakarta.persistence.PersistenceContext; import java.time.LocalDate; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; -import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.*; @@ -34,17 +34,11 @@ import static org.hamcrest.Matchers.*; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional -class HsOfficeSepaMandateControllerAcceptanceTest { +class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort private Integer port; - @Autowired - Context context; - - @Autowired - Context contextMock; - @Autowired HsOfficeSepaMandateRepository sepaMandateRepo; @@ -61,7 +55,6 @@ class HsOfficeSepaMandateControllerAcceptanceTest { EntityManager em; @Nested - @Accepts({ "SepaMandate:F(Find)" }) class ListSepaMandates { @Test @@ -76,35 +69,27 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .then().log().all().assertThat() .statusCode(200) .contentType("application/json") + .log().all() .body("", lenientlyEquals(""" [ { - "debitor": { - "debitorNumber": 1000212, - "billingContact": { "label": "second contact" } - }, - "bankAccount": { "holder": "Second e.K." }, - "reference": "refSeconde.K.", - "validFrom": "2022-10-01", - "validTo": "2026-12-31" - }, - { - "debitor": { - "debitorNumber": 1000111, - "billingContact": { "label": "first contact" } - }, + "debitor": { "debitorNumber": 1000111 }, "bankAccount": { "holder": "First GmbH" }, - "reference": "refFirstGmbH", + "reference": "ref-10001-11", "validFrom": "2022-10-01", "validTo": "2026-12-31" }, { - "debitor": { - "debitorNumber": 1000313, - "billingContact": { "label": "third contact" } - }, + "debitor": { "debitorNumber": 1000212 }, + "bankAccount": { "holder": "Second e.K." }, + "reference": "ref-10002-12", + "validFrom": "2022-10-01", + "validTo": "2026-12-31" + }, + { + "debitor": { "debitorNumber": 1000313 }, "bankAccount": { "holder": "Third OHG" }, - "reference": "refThirdOHG", + "reference": "ref-10003-13", "validFrom": "2022-10-01", "validTo": "2026-12-31" } @@ -115,7 +100,6 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Nested - @Accepts({ "SepaMandate:C(Create)" }) class AddSepaMandate { @Test @@ -123,7 +107,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0); - final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0); + final var givenBankAccount = bankAccountRepo.findByIbanOrderByIbanAsc("DE02200505501015871393").get(0); final var location = RestAssured // @formatter:off .given() @@ -145,7 +129,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("debitor.partner.person.tradeName", is("Third OHG")) + .body("debitor.partner.partnerNumber", is(10003)) .body("bankAccount.iban", is("DE02200505501015871393")) .body("reference", is("temp ref CAT A")) .body("validFrom", is("2022-10-13")) @@ -165,7 +149,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0); - final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0); + final var givenBankAccount = bankAccountRepo.findByIbanOrderByIbanAsc("DE02200505501015871393").get(0); final var location = RestAssured // @formatter:off .given() @@ -190,7 +174,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0); - final var givenBankAccountUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenBankAccountUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); final var location = RestAssured // @formatter:off .given() @@ -211,7 +195,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .post("http://localhost/api/hs/office/sepamandates") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find BankAccount with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [400] Unable to find BankAccount by uuid: 00000000-0000-0000-0000-000000000000")); // @formatter:on } @@ -219,8 +203,8 @@ class HsOfficeSepaMandateControllerAcceptanceTest { void globalAdmin_canNotAddSepaMandate_ifPersonDoesNotExist() { context.define("superuser-alex@hostsharing.net"); - final var givenDebitorUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0); + final var givenDebitorUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); + final var givenBankAccount = bankAccountRepo.findByIbanOrderByIbanAsc("DE02200505501015871393").get(0); final var location = RestAssured // @formatter:off .given() @@ -241,13 +225,12 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .post("http://localhost/api/hs/office/sepamandates") .then().log().all().assertThat() .statusCode(400) - .body("message", is("Unable to find Debitor with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + .body("message", is("ERROR: [400] Unable to find Debitor by uuid: 00000000-0000-0000-0000-000000000000")); // @formatter:on } } @Nested - @Accepts({ "SepaMandate:R(Read)" }) class GetSepaMandate { @Test @@ -268,15 +251,12 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "debitor": { - "debitorNumber": 1000111, - "billingContact": { "label": "first contact" } - }, + "debitor": { "debitorNumber": 1000111 }, "bankAccount": { "holder": "First GmbH", "iban": "DE02120300000000202051" }, - "reference": "refFirstGmbH", + "reference": "ref-10001-11", "validFrom": "2022-10-01", "validTo": "2026-12-31" } @@ -284,7 +264,6 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Test - @Accepts({ "SepaMandate:X(Access Control)" }) void normalUser_canNotGetUnrelatedSepaMandate() { context.define("superuser-alex@hostsharing.net"); final var givenSepaMandateUuid = sepaMandateRepo.findSepaMandateByOptionalIban("DE02120300000000202051") @@ -302,7 +281,6 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Test - @Accepts({ "SepaMandate:X(Access Control)" }) void bankAccountAdminUser_canGetRelatedSepaMandate() { context.define("superuser-alex@hostsharing.net"); final var givenSepaMandateUuid = sepaMandateRepo.findSepaMandateByOptionalIban("DE02120300000000202051") @@ -320,15 +298,12 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .contentType("application/json") .body("", lenientlyEquals(""" { - "debitor": { - "debitorNumber": 1000111, - "billingContact": { "label": "first contact" } - }, + "debitor": { "debitorNumber": 1000111 }, "bankAccount": { "holder": "First GmbH", "iban": "DE02120300000000202051" }, - "reference": "refFirstGmbH", + "reference": "ref-10001-11", "validFrom": "2022-10-01", "validTo": "2026-12-31" } @@ -337,13 +312,12 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Nested - @Accepts({ "SepaMandate:U(Update)" }) class PatchSepaMandate { @Test void globalAdmin_canPatchAllUpdatablePropertiesOfSepaMandate() { - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); final var location = RestAssured // @formatter:off .given() @@ -364,7 +338,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("debitor.partner.person.tradeName", is("First GmbH")) + .body("debitor.debitorNumber", is(1000111)) .body("bankAccount.iban", is("DE02120300000000202051")) .body("reference", is("temp ref CAT Z - patched")) .body("agreement", is("2020-06-01")) @@ -376,7 +350,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { context.define("superuser-alex@hostsharing.net"); assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: LP First GmbH: fir)"); + assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)"); assertThat(mandate.getBankAccount().toShortString()).isEqualTo("First GmbH"); assertThat(mandate.getReference()).isEqualTo("temp ref CAT Z - patched"); assertThat(mandate.getValidFrom()).isEqualTo("2020-06-05"); @@ -389,7 +363,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { void globalAdmin_canPatchJustValidToOfArbitrarySepaMandate() { context.define("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); final var location = RestAssured // @formatter:off .given() @@ -407,7 +381,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { .statusCode(200) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("debitor.partner.person.tradeName", is("First GmbH")) + .body("debitor.debitorNumber", is(1000111)) .body("bankAccount.iban", is("DE02120300000000202051")) .body("reference", is("temp ref CAT Z")) .body("validFrom", is("2022-11-01")) @@ -417,7 +391,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { // finally, the sepaMandate is actually updated assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: LP First GmbH: fir)"); + assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)"); assertThat(mandate.getBankAccount().toShortString()).isEqualTo("First GmbH"); assertThat(mandate.getReference()).isEqualTo("temp ref CAT Z"); assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2023-01-01)"); @@ -429,7 +403,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest { void globalAdmin_canNotPatchReferenceOfArbitrarySepaMandate() { context.define("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); final var location = RestAssured // @formatter:off .given() @@ -458,13 +432,12 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Nested - @Accepts({ "SepaMandate:D(Delete)" }) class DeleteSepaMandate { @Test void globalAdmin_canDeleteArbitrarySepaMandate() { context.define("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); RestAssured // @formatter:off .given() @@ -480,10 +453,9 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Test - @Accepts({ "SepaMandate:X(Access Control)" }) void bankAccountAdminUser_canNotDeleteRelatedSepaMandate() { context.define("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); RestAssured // @formatter:off .given() @@ -499,10 +471,9 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } @Test - @Accepts({ "SepaMandate:X(Access Control)" }) void normalUser_canNotDeleteUnrelatedSepaMandate() { context.define("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandate(); + final var givenSepaMandate = givenSomeTemporarySepaMandateForDebitorNumber(1000111); RestAssured // @formatter:off .given() @@ -518,11 +489,13 @@ class HsOfficeSepaMandateControllerAcceptanceTest { } } - private HsOfficeSepaMandateEntity givenSomeTemporarySepaMandate() { + private HsOfficeSepaMandateEntity givenSomeTemporarySepaMandateForDebitorNumber(final int debitorNumber) { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); - final var givenBankAccount = bankAccountRepo.findByOptionalHolderLike("First").get(0); + final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(debitorNumber).get(0); + final var bankAccountHolder = ofNullable(givenDebitor.getPartner().getPartnerRel().getHolder().getTradeName()) + .orElse(givenDebitor.getPartner().getPartnerRel().getHolder().getFamilyName()); + final var givenBankAccount = bankAccountRepo.findByOptionalHolderLike(bankAccountHolder).get(0); final var newSepaMandate = HsOfficeSepaMandateEntity.builder() .uuid(UUID.randomUUID()) .debitor(givenDebitor) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityPatcherUnitTest.java index 05f4ca07..32b27caf 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityPatcherUnitTest.java @@ -1,9 +1,9 @@ package net.hostsharing.hsadminng.hs.office.sepamandate; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeSepaMandatePatchResource; -import net.hostsharing.test.PatchUnitTestBase; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityUnitTest.java index 5d8fa5b5..aaa40e7c 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateEntityUnitTest.java @@ -17,14 +17,14 @@ class HsOfficeSepaMandateEntityUnitTest { .debitor(TEST_DEBITOR) .reference("some-ref") .validity(toPostgresDateRange(GIVEN_VALID_FROM, GIVEN_VALID_TO)) - .bankAccount(HsOfficeBankAccountEntity.builder().iban("some label").build()) + .bankAccount(HsOfficeBankAccountEntity.builder().iban("some caption").build()) .build(); @Test void toStringContainsReferenceAndBankAccount() { final var result = givenSepaMandate.toString(); - assertThat(result).isEqualTo("SEPA-Mandate(some label, some-ref, [2020-01-01,2031-01-01))"); + assertThat(result).isEqualTo("SEPA-Mandate(some caption, some-ref, [2020-01-01,2031-01-01))"); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java index 25d0343b..3fb90976 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java @@ -1,16 +1,14 @@ package net.hostsharing.hsadminng.hs.office.sepamandate; -import com.vladmihalcea.hibernate.type.range.Range; +import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,7 +16,6 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.orm.jpa.JpaSystemException; -import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -27,14 +24,15 @@ import java.time.LocalDate; import java.util.Arrays; import java.util.List; -import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; -import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import({ Context.class, JpaAttempt.class }) -class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { +class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Autowired HsOfficeSepaMandateRepository sepaMandateRepo; @@ -81,7 +79,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { .validity(Range.closedOpen( LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) .build(); - return sepaMandateRepo.save(newSepaMandate); + return toCleanup(sepaMandateRepo.save(newSepaMandate)); }); // then @@ -95,10 +93,8 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("-firstcontact", "-...")) - .map(s -> s.replace("PaulWinkler", "Paul...")) + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() .map(s -> s.replace("hs_office_", "")) .toList(); @@ -114,54 +110,49 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { .validity(Range.closedOpen( LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) .build(); - return sepaMandateRepo.save(newSepaMandate); + return toCleanup(sepaMandateRepo.save(newSepaMandate)); }); // then final var all = rawRoleRepo.findAll(); - assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_office_sepamandate#temprefB.owner", - "hs_office_sepamandate#temprefB.admin", - "hs_office_sepamandate#temprefB.agent", - "hs_office_sepamandate#temprefB.tenant", - "hs_office_sepamandate#temprefB.guest")); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("-firstcontact", "-...")) - .map(s -> s.replace("PaulWinkler", "Paul...")) + "hs_office_sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):ADMIN", + "hs_office_sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):AGENT", + "hs_office_sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER", + "hs_office_sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) .map(s -> s.replace("hs_office_", "")) - .containsExactlyInAnyOrder(Array.fromFormatted( + .containsExactlyInAnyOrder(fromFormatted( initialGrantNames, // owner - "{ grant perm * on sepamandate#temprefB to role sepamandate#temprefB.owner by system and assume }", - "{ grant role sepamandate#temprefB.owner to role global#global.admin by system and assume }", + "{ grant perm:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):DELETE to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER to role:global#global:ADMIN by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER to user:superuser-alex@hostsharing.net by sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER and assume }", // admin - "{ grant perm edit on sepamandate#temprefB to role sepamandate#temprefB.admin by system and assume }", - "{ grant role sepamandate#temprefB.admin to role sepamandate#temprefB.owner by system and assume }", - "{ grant role bankaccount#Paul....tenant to role sepamandate#temprefB.admin by system and assume }", + "{ grant perm:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):UPDATE to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):ADMIN by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):ADMIN to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):OWNER by system and assume }", // agent - "{ grant role sepamandate#temprefB.agent to role sepamandate#temprefB.admin by system and assume }", - "{ grant role debitor#1000111:FirstGmbH-....tenant to role sepamandate#temprefB.agent by system and assume }", - "{ grant role sepamandate#temprefB.agent to role bankaccount#Paul....admin by system and assume }", - "{ grant role sepamandate#temprefB.agent to role debitor#1000111:FirstGmbH-....admin by system and assume }", + "{ grant role:bankaccount#DE02600501010002034304:REFERRER to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):AGENT by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):AGENT to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):AGENT by system and assume }", - // tenant - "{ grant role sepamandate#temprefB.tenant to role sepamandate#temprefB.agent by system and assume }", - "{ grant role debitor#1000111:FirstGmbH-....guest to role sepamandate#temprefB.tenant by system and assume }", - "{ grant role bankaccount#Paul....guest to role sepamandate#temprefB.tenant by system and assume }", + // referrer + "{ grant perm:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):SELECT to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):AGENT by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER to role:bankaccount#DE02600501010002034304:ADMIN by system and assume }", + "{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:TENANT to role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER by system and assume }", + "{ grant role:sepamandate#DE02600501010002034304-[2020-01-01,2023-01-01):REFERRER to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system and assume }", - // guest - "{ grant perm view on sepamandate#temprefB to role sepamandate#temprefB.guest by system and assume }", - "{ grant role sepamandate#temprefB.guest to role sepamandate#temprefB.tenant by system and assume }", null)); } private void assertThatSepaMandateIsPersisted(final HsOfficeSepaMandateEntity saved) { final var found = sepaMandateRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -179,9 +170,9 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then allTheseSepaMandatesAreReturned( result, - "SEPA-Mandate(DE02120300000000202051, refFirstGmbH, 2022-09-30, [2022-10-01,2027-01-01))", - "SEPA-Mandate(DE02100500000054540402, refSeconde.K., 2022-09-30, [2022-10-01,2027-01-01))", - "SEPA-Mandate(DE02300209000106531065, refThirdOHG, 2022-09-30, [2022-10-01,2027-01-01))"); + "SEPA-Mandate(DE02100500000054540402, ref-10002-12, 2022-09-30, [2022-10-01,2027-01-01))", + "SEPA-Mandate(DE02120300000000202051, ref-10001-11, 2022-09-30, [2022-10-01,2027-01-01))", + "SEPA-Mandate(DE02300209000106531065, ref-10003-13, 2022-09-30, [2022-10-01,2027-01-01))"); } @Test @@ -195,7 +186,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then: exactlyTheseSepaMandatesAreReturned( result, - "SEPA-Mandate(DE02120300000000202051, refFirstGmbH, 2022-09-30, [2022-10-01,2027-01-01))"); + "SEPA-Mandate(DE02120300000000202051, ref-10001-11, 2022-09-30, [2022-10-01,2027-01-01))"); } } @@ -213,9 +204,9 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseSepaMandatesAreReturned( result, - "SEPA-Mandate(DE02120300000000202051, refFirstGmbH, 2022-09-30, [2022-10-01,2027-01-01))", - "SEPA-Mandate(DE02100500000054540402, refSeconde.K., 2022-09-30, [2022-10-01,2027-01-01))", - "SEPA-Mandate(DE02300209000106531065, refThirdOHG, 2022-09-30, [2022-10-01,2027-01-01))"); + "SEPA-Mandate(DE02100500000054540402, ref-10002-12, 2022-09-30, [2022-10-01,2027-01-01))", + "SEPA-Mandate(DE02120300000000202051, ref-10001-11, 2022-09-30, [2022-10-01,2027-01-01))", + "SEPA-Mandate(DE02300209000106531065, ref-10003-13, 2022-09-30, [2022-10-01,2027-01-01))"); } @Test @@ -229,7 +220,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseSepaMandatesAreReturned( result, - "SEPA-Mandate(DE02300209000106531065, refThirdOHG, 2022-09-30, [2022-10-01,2027-01-01))"); + "SEPA-Mandate(DE02300209000106531065, ref-10003-13, 2022-09-30, [2022-10-01,2027-01-01))"); } } @@ -239,10 +230,10 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { @Test public void hostsharingAdmin_canUpdateArbitrarySepaMandate() { // given - final var givenSepaMandate = givenSomeTemporarySepaMandateBessler("Peter Smith"); + final var givenSepaMandate = givenSomeTemporarySepaMandate("DE02600501010002034304"); assertThatSepaMandateIsVisibleForUserWithRole( givenSepaMandate, - "hs_office_bankaccount#PeterSmith.admin"); + "hs_office_bankaccount#DE02600501010002034304:ADMIN"); // when final var result = jpaAttempt.transacted(() -> { @@ -251,7 +242,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { givenSepaMandate.setAgreement(LocalDate.parse("2019-05-13")); givenSepaMandate.setValidity(Range.closedOpen( LocalDate.parse("2019-05-17"), LocalDate.parse("2023-01-01"))); - return sepaMandateRepo.save(givenSepaMandate); + return toCleanup(sepaMandateRepo.save(givenSepaMandate)); }); // then @@ -259,7 +250,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isNotEmpty().get() - .usingRecursiveComparison().isEqualTo(givenSepaMandate); + .extracting(Object::toString).isEqualTo(givenSepaMandate.toString()); }).assertSuccessful(); } @@ -267,19 +258,21 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void bankAccountAdmin_canViewButNotUpdateRelatedSepaMandate() { // given context("superuser-alex@hostsharing.net"); - final var givenSepaMandate = givenSomeTemporarySepaMandateBessler("Anita Bessler"); + + final var givenSepaMandate = givenSomeTemporarySepaMandate("DE02300606010002474689"); assertThatSepaMandateIsVisibleForUserWithRole( givenSepaMandate, - "hs_office_bankaccount#AnitaBessler.admin"); + "hs_office_bankaccount#DE02300606010002474689:ADMIN"); assertThatSepaMandateActuallyInDatabase(givenSepaMandate); final var newValidityEnd = LocalDate.now(); // when final var result = jpaAttempt.transacted(() -> { - context("superuser-alex@hostsharing.net", "hs_office_bankaccount#AnitaBessler.admin"); + context("superuser-alex@hostsharing.net", "hs_office_bankaccount#DE02300606010002474689:ADMIN"); + givenSepaMandate.setValidity(Range.closedOpen( givenSepaMandate.getValidity().lower(), newValidityEnd)); - return sepaMandateRepo.save(givenSepaMandate); + return toCleanup(sepaMandateRepo.save(givenSepaMandate)); }); // then @@ -320,7 +313,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void globalAdmin_withoutAssumedRole_canDeleteAnySepaMandate() { // given context("superuser-alex@hostsharing.net", null); - final var givenSepaMandate = givenSomeTemporarySepaMandateBessler("Fourth e.G."); + final var givenSepaMandate = givenSomeTemporarySepaMandate("DE02200505501015871393"); // when final var result = jpaAttempt.transacted(() -> { @@ -340,7 +333,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void nonGlobalAdmin_canNotDeleteTheirRelatedSepaMandate() { // given context("superuser-alex@hostsharing.net", null); - final var givenSepaMandate = givenSomeTemporarySepaMandateBessler("Third OHG"); + final var givenSepaMandate = givenSomeTemporarySepaMandate("DE02300209000106531065"); // when final var result = jpaAttempt.transacted(() -> { @@ -364,13 +357,9 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void deletingASepaMandateAlsoDeletesRelatedRolesAndGrants() { // given context("superuser-alex@hostsharing.net"); - final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); - final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); - final var givenSepaMandate = givenSomeTemporarySepaMandateBessler("Mel Bessler"); - assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created") - .isEqualTo(initialRoleNames.length + 5); - assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created") - .isEqualTo(initialGrantNames.length + 14); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenSepaMandate = givenSomeTemporarySepaMandate("DE02600501010002034304"); // when final var result = jpaAttempt.transacted(() -> { @@ -381,8 +370,8 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then result.assertSuccessful(); assertThat(result.returnedValue()).isEqualTo(1); - assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); - assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); } } @@ -390,9 +379,8 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { public void auditJournalLogIsAvailable() { // given final var query = em.createNativeQuery(""" - select c.currenttask, j.targettable, j.targetop - from tx_journal j - join tx_context c on j.contextId = c.contextId + select currentTask, targetTable, targetOp, targetdelta->>'reference' + from tx_journal_v where targettable = 'hs_office_sepamandate'; """); @@ -401,23 +389,16 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating SEPA-mandate test-data FirstGmbH, hs_office_sepamandate, INSERT]", - "[creating SEPA-mandate test-data Seconde.K., hs_office_sepamandate, INSERT]"); + "[creating SEPA-mandate test-data, hs_office_sepamandate, INSERT, ref-10001-11]", + "[creating SEPA-mandate test-data, hs_office_sepamandate, INSERT, ref-10002-12]", + "[creating SEPA-mandate test-data, hs_office_sepamandate, INSERT, ref-10003-13]"); } - @BeforeEach - @AfterEach - @Transactional - void cleanup() { - context("superuser-alex@hostsharing.net", null); - em.createQuery("DELETE FROM HsOfficeSepaMandateEntity WHERE reference like 'temp ref%'").executeUpdate(); - } - - private HsOfficeSepaMandateEntity givenSomeTemporarySepaMandateBessler(final String bankAccountHolder) { + private HsOfficeSepaMandateEntity givenSomeTemporarySepaMandate(final String iban) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); - final var givenBankAccount = bankAccountRepo.findByOptionalHolderLike(bankAccountHolder).get(0); + final var givenBankAccount = bankAccountRepo.findByIbanOrderByIbanAsc(iban).get(0); final var newSepaMandate = HsOfficeSepaMandateEntity.builder() .debitor(givenDebitor) .bankAccount(givenBankAccount) @@ -427,7 +408,7 @@ class HsOfficeSepaMandateRepositoryIntegrationTest extends ContextBasedTest { LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01"))) .build(); - return sepaMandateRepo.save(newSepaMandate); + return toCleanup(sepaMandateRepo.save(newSepaMandate)); }).assertSuccessful().returnedValue(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java new file mode 100644 index 00000000..64ca8236 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java @@ -0,0 +1,128 @@ +package net.hostsharing.hsadminng.hs.validation; + +import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator; +import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_SHA512; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; +import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class PasswordPropertyUnitTest { + + private final ValidatableProperty passwordProp = + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(LINUX_SHA512).writeOnly(); + private final List violations = new ArrayList<>(); + private EntityManager em = null; // not actually needed in these test cases + + @ParameterizedTest + @ValueSource(strings = { + "lowerUpperAndDigit1", + "lowerUpperAndSpecial!", + "digit1LowerAndSpecial!", + "digit1special!lower", + "DIGIT1SPECIAL!UPPER" }) + void shouldValidateValidPassword(final String password) { + // when + passwordProp.validate(violations, password, null); + + // then + assertThat(violations).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "noDigitNoSpecial", + "!!!!!!12345", + "nolower-nodigit", + "nolower1nospecial", + "NOLOWER-NODIGIT", + "NOLOWER1NOSPECIAL" + }) + void shouldRecognizeMissingCharacterGroup(final String givenPassword) { + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeTooShortPassword() { + // given + final String givenPassword = "0123456"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' length is expected to be at min 8 but length of provided value is 7") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeTooLongPassowrd() { + // given + final String givenPassword = "password' length is expected to be at max 40 but is 41"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations).contains("password' length is expected to be at max 40 but length of provided value is 54") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeColonInPassword() { + // given + final String givenPassword = "lowerUpper:1234"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' must not contain colon (':')") + .doesNotContain(givenPassword); + } + + @Test + void shouldComputeHash() { + + // when + final var result = passwordProp.compute(em, new PropertiesProvider() { + + @Override + public boolean isLoaded() { + return false; + } + + @Override + public PatchableMapWrapper directProps() { + return PatchableMapWrapper.of(Map.ofEntries( + entry(passwordProp.propertyName, "some password") + )); + } + + @Override + public Object getContextValue(final String propName) { + return null; + } + }); + + // then + LinuxEtcShadowHashGenerator.verify(result, "some password"); // throws exception if wrong + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/mapper/PostgresArrayIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/mapper/PostgresArrayIntegrationTest.java index 8f3e95e0..3542caa1 100644 --- a/src/test/java/net/hostsharing/hsadminng/mapper/PostgresArrayIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/mapper/PostgresArrayIntegrationTest.java @@ -7,7 +7,6 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import jakarta.persistence.EntityManager; import java.util.UUID; -import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; @@ -30,9 +29,7 @@ class PostgresArrayIntegrationTest { return emptyArray; end; $$; """).executeUpdate(); - final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnEmptyArray()", String[].class).getSingleResult(); - - final String[] result = PostgresArray.fromPostgresArray(pgArray, String.class, Function.identity()); + final String[] result = (String[]) em.createNativeQuery("SELECT returnEmptyArray()", String[].class).getSingleResult(); assertThat(result).isEmpty(); } @@ -53,9 +50,7 @@ class PostgresArrayIntegrationTest { return array[text1, text2, text3, null, text4]; end; $$; """).executeUpdate(); - final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnStringArray()", String[].class).getSingleResult(); - - final String[] result = PostgresArray.fromPostgresArray(pgArray, String.class, Function.identity()); + final String[] result = (String[]) em.createNativeQuery("SELECT returnStringArray()", String[].class).getSingleResult(); assertThat(result).containsExactly("one", "two, three", "four; five", null, "say \"Hello\" to me"); } @@ -75,9 +70,7 @@ class PostgresArrayIntegrationTest { return ARRAY[uuid1, uuid2, null, uuid3]; end; $$; """).executeUpdate(); - final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnUuidArray()", UUID[].class).getSingleResult(); - - final UUID[] result = PostgresArray.fromPostgresArray(pgArray, UUID.class, UUID::fromString); + final UUID[] result = (UUID[]) em.createNativeQuery("SELECT returnUuidArray()", UUID[].class).getSingleResult(); assertThat(result).containsExactly( UUID.fromString("f47ac10b-58cc-4372-a567-0e02b2c3d479"), diff --git a/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java new file mode 100644 index 00000000..f9db2070 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/persistence/EntityManagerWrapperUnitTest.java @@ -0,0 +1,61 @@ +package net.hostsharing.hsadminng.persistence; + +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import jakarta.persistence.EntityManager; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +class EntityManagerWrapperUnitTest { + private EntityManagerWrapper wrapper; + private EntityManager delegateMock; + + @BeforeEach + public void setUp() { + delegateMock = mock(EntityManager.class); + wrapper = new EntityManagerWrapper(delegateMock); + } + + @Test + public void testAllMethodsAreForwarded() throws Exception { + final var methods = EntityManager.class.getMethods(); + + for (Method method : methods) { + // given prepared dummy arguments (if any) + final var args = Arrays.stream(method.getParameterTypes()) + .map(this::getDefaultValue) + .toArray(); + + // when + method.invoke(wrapper, args); + + // then verify that the same method was called on the mock delegate + Mockito.verify(delegateMock, times(1)).getClass() + .getMethod(method.getName(), method.getParameterTypes()) + .invoke(delegateMock, args); + } + } + + private Object getDefaultValue(Class type) { + if (type == boolean.class) return false; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0f; + if (type == double.class) return 0.0; + if (type == char.class) return '\0'; + if (type == String.class) return "dummy"; + if (type == String[].class) return Array.of("dummy"); + if (type == Class.class) return String.class; + if (type == Class[].class) return Array.of(String.class); + return mock(type); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextBasedTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextBasedTest.java new file mode 100644 index 00000000..59704ad4 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextBasedTest.java @@ -0,0 +1,73 @@ +package net.hostsharing.hsadminng.rbac.context; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.sql.Timestamp; + +@Import(RbacGrantsDiagramService.class) +public abstract class ContextBasedTest { + + @Autowired + protected Context context; + + @PersistenceContext + protected EntityManager em; // just to be used in subclasses + + /** + * To generate a flowchart diagram from the database use something like this in a defined context: + +
+     RbacGrantsDiagramService.writeToFile(
+         "title",
+         diagramService.allGrantsToCurrentUser(of(RbacGrantsDiagramService.Include.USERS, RbacGrantsDiagramService.Include.TEST_ENTITIES, RbacGrantsDiagramService.Include.NOT_ASSUMED, RbacGrantsDiagramService.Include.DETAILS, RbacGrantsDiagramService.Include.PERMISSIONS)),
+         "filename.md
+     );
+    
+ */ + @Autowired + protected RbacGrantsDiagramService diagramService; // just to be used in subclasses + + TestInfo test; + + @BeforeEach + void init(TestInfo testInfo) { + this.test = testInfo; + } + + protected void context(final String currentUser, final String assumedRoles) { + context.define(test.getDisplayName(), null, currentUser, assumedRoles); + } + + protected void context(final String currentUser) { + context(currentUser, null); + } + + protected void historicalContext(final Long txId) { + // set local cannot be used with query parameters + em.createNativeQuery(""" + set local hsadminng.tx_history_txid to ':txid'; + """.replace(":txid", txId.toString())).executeUpdate(); + em.createNativeQuery(""" + set local hsadminng.tx_history_timestamp to ''; + """).executeUpdate(); + } + + + protected void historicalContext(final Timestamp txTimestamp) { + // set local cannot be used with query parameters + em.createNativeQuery(""" + set local hsadminng.tx_history_timestamp to ':timestamp'; + """.replace(":timestamp", txTimestamp.toString())).executeUpdate(); + em.createNativeQuery(""" + set local hsadminng.tx_history_txid to ''; + """).executeUpdate(); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java similarity index 82% rename from src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java rename to src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java index c02cb944..22e1df04 100644 --- a/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java @@ -1,7 +1,9 @@ -package net.hostsharing.hsadminng.context; +package net.hostsharing.hsadminng.rbac.context; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.mapper.Mapper; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -15,7 +17,7 @@ import jakarta.servlet.http.HttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@ComponentScan(basePackageClasses = { Context.class, JpaAttempt.class }) +@ComponentScan(basePackageClasses = { Context.class, JpaAttempt.class, Mapper.class }) @DirtiesContext class ContextIntegrationTests { @@ -23,6 +25,7 @@ class ContextIntegrationTests { private Context context; @MockBean + @SuppressWarnings("unused") // the bean must be present, even though it's not used directly private HttpServletRequest request; @Autowired @@ -59,13 +62,13 @@ class ContextIntegrationTests { void defineWithoutCurrentUserButWithAssumedRoles() { // when final var result = jpaAttempt.transacted(() -> - context.define(null, "test_package#yyy00.admin") + context.define(null, "test_package#yyy00:ADMIN") ); // then result.assertExceptionWithRootCauseMessage( jakarta.persistence.PersistenceException.class, - "ERROR: [403] undefined has no permission to assume role test_package#yyy00.admin"); + "ERROR: [403] undefined has no permission to assume role test_package#yyy00:ADMIN"); } @Test @@ -85,7 +88,7 @@ class ContextIntegrationTests { @Transactional void defineWithCurrentUserAndAssumedRoles() { // given - context.define("superuser-alex@hostsharing.net", "test_customer#xxx.owner;test_customer#yyy.owner"); + context.define("superuser-alex@hostsharing.net", "test_customer#xxx:OWNER;test_customer#yyy:OWNER"); // when final var currentUser = context.getCurrentUser(); @@ -93,7 +96,7 @@ class ContextIntegrationTests { // then assertThat(context.getAssumedRoles()) - .isEqualTo(Array.of("test_customer#xxx.owner", "test_customer#yyy.owner")); + .isEqualTo(Array.of("test_customer#xxx:OWNER", "test_customer#yyy:OWNER")); assertThat(context.currentSubjectsUuids()).hasSize(2); } @@ -101,12 +104,12 @@ class ContextIntegrationTests { public void defineContextWithCurrentUserAndAssumeInaccessibleRole() { // when final var result = jpaAttempt.transacted(() -> - context.define("customer-admin@xxx.example.com", "test_package#yyy00.admin") + context.define("customer-admin@xxx.example.com", "test_package#yyy00:ADMIN") ); // then result.assertExceptionWithRootCauseMessage( jakarta.persistence.PersistenceException.class, - "ERROR: [403] user customer-admin@xxx.example.com has no permission to assume role test_package#yyy00.admin"); + "ERROR: [403] user customer-admin@xxx.example.com has no permission to assume role test_package#yyy00:ADMIN"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/context/ContextUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextUnitTest.java similarity index 80% rename from src/test/java/net/hostsharing/hsadminng/context/ContextUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/context/ContextUnitTest.java index af78c76a..ae64d8c1 100644 --- a/src/test/java/net/hostsharing/hsadminng/context/ContextUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextUnitTest.java @@ -1,5 +1,6 @@ -package net.hostsharing.hsadminng.context; +package net.hostsharing.hsadminng.rbac.context; +import net.hostsharing.hsadminng.context.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -27,12 +28,12 @@ import static org.mockito.Mockito.verify; class ContextUnitTest { private static final String DEFINE_CONTEXT_QUERY_STRING = """ - call defineContext( - cast(:currentTask as varchar), - cast(:currentRequest as varchar), - cast(:currentUser as varchar), - cast(:assumedRoles as varchar)); - """; + call defineContext( + cast(:currentTask as varchar(127)), + cast(:currentRequest as text), + cast(:currentUser as varchar(63)), + cast(:assumedRoles as varchar(1023))); + """; @Nested class WithoutHttpRequest { @@ -71,7 +72,7 @@ class ContextUnitTest { context.define("current-user"); verify(em).createNativeQuery(DEFINE_CONTEXT_QUERY_STRING); - verify(nativeQuery).setParameter("currentRequest", ""); + verify(nativeQuery).setParameter("currentRequest", null); } } @@ -142,8 +143,8 @@ class ContextUnitTest { } @Test - void shortensCurrentTaskTo96Chars() throws IOException { - givenRequest("GET", "http://localhost:9999/api/endpoint/" + "0123456789".repeat(10), + void shortensCurrentTaskToMaxLength() throws IOException { + givenRequest("GET", "http://localhost:9999/api/endpoint/" + "0123456789".repeat(13), Map.ofEntries( Map.entry("current-user", "given-user"), Map.entry("content-type", "application/json"), @@ -153,26 +154,7 @@ class ContextUnitTest { context.define("current-user"); verify(em).createNativeQuery(DEFINE_CONTEXT_QUERY_STRING); - verify(nativeQuery).setParameter(eq("currentTask"), argThat((String t) -> t.length() == 96)); - } - - @Test - void shortensCurrentRequestTo512Chars() throws IOException { - givenRequest("GET", "http://localhost:9999/api/endpoint", - Map.ofEntries( - Map.entry("current-user", "given-user"), - Map.entry("content-type", "application/json"), - Map.entry("user-agent", "given-user-agent")), - """ - { - "dummy": "%s" - } - """.formatted("0123456789".repeat(60))); - - context.define("current-user"); - - verify(em).createNativeQuery(DEFINE_CONTEXT_QUERY_STRING); - verify(nativeQuery).setParameter(eq("currentRequest"), argThat((String t) -> t.length() == 512)); + verify(nativeQuery).setParameter(eq("currentTask"), argThat((String t) -> t.length() == 127)); } private void givenRequest(final String method, final String url, final Map headers, final String body) diff --git a/src/test/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCacheUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/HttpServletRequestBodyCacheUnitTest.java similarity index 96% rename from src/test/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCacheUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/context/HttpServletRequestBodyCacheUnitTest.java index 4903b594..598b9b8f 100644 --- a/src/test/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCacheUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/HttpServletRequestBodyCacheUnitTest.java @@ -1,5 +1,6 @@ -package net.hostsharing.hsadminng.context; +package net.hostsharing.hsadminng.rbac.context; +import net.hostsharing.hsadminng.context.HttpServletRequestBodyCache; import org.junit.jupiter.api.Test; import java.io.IOException; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java index 6f0abc93..aa2f0afb 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java @@ -4,13 +4,12 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.response.ValidatableResponse; import net.hostsharing.hsadminng.HsadminNgApplication; -import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleEntity; import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserEntity; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -34,7 +33,6 @@ import static org.hamcrest.Matchers.*; webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { HsadminNgApplication.class, JpaAttempt.class } ) -@Accepts({ "GRT:S(Schema)" }) @Transactional(readOnly = true, propagation = Propagation.NEVER) class RbacGrantControllerAcceptanceTest extends ContextBasedTest { @@ -60,7 +58,6 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { class ListGrants { @Test - @Accepts("GRT:L(List)") void globalAdmin_withoutAssumedRole_canViewAllGrants() { RestAssured // @formatter:off .given() @@ -73,36 +70,38 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "global#global.admin"), - hasEntry("grantedRoleIdName", "test_customer#xxx.admin"), + // TODO: should there be a grantedByRole or just a grantedByTrigger? + hasEntry("grantedByRoleIdName", "test_customer#xxx:OWNER"), + hasEntry("grantedRoleIdName", "test_customer#xxx:ADMIN"), hasEntry("granteeUserName", "customer-admin@xxx.example.com") ) )) .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "global#global.admin"), - hasEntry("grantedRoleIdName", "test_customer#yyy.admin"), + // TODO: should there be a grantedByRole or just a grantedByTrigger? + hasEntry("grantedByRoleIdName", "test_customer#yyy:OWNER"), + hasEntry("grantedRoleIdName", "test_customer#yyy:ADMIN"), hasEntry("granteeUserName", "customer-admin@yyy.example.com") ) )) .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "global#global.admin"), - hasEntry("grantedRoleIdName", "global#global.admin"), + hasEntry("grantedByRoleIdName", "global#global:ADMIN"), + hasEntry("grantedRoleIdName", "global#global:ADMIN"), hasEntry("granteeUserName", "superuser-fran@hostsharing.net") ) )) .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "test_customer#xxx.admin"), - hasEntry("grantedRoleIdName", "test_package#xxx00.admin"), + hasEntry("grantedByRoleIdName", "test_customer#xxx:ADMIN"), + hasEntry("grantedRoleIdName", "test_package#xxx00:ADMIN"), hasEntry("granteeUserName", "pac-admin-xxx00@xxx.example.com") ) )) .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "test_customer#zzz.admin"), - hasEntry("grantedRoleIdName", "test_package#zzz02.admin"), + hasEntry("grantedByRoleIdName", "test_customer#zzz:ADMIN"), + hasEntry("grantedRoleIdName", "test_package#zzz02:ADMIN"), hasEntry("granteeUserName", "pac-admin-zzz02@zzz.example.com") ) )) @@ -111,12 +110,11 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { } @Test - @Accepts({ "GRT:L(List)", "GRT:X(Access Control)" }) void globalAdmin_withAssumedPackageAdminRole_canViewPacketRelatedGrants() { RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_package#yyy00.admin") + .header("assumed-roles", "test_package#yyy00:ADMIN") .port(port) .when() .get("http://localhost/api/rbac/grants") @@ -125,8 +123,8 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "test_customer#yyy.admin"), - hasEntry("grantedRoleIdName", "test_package#yyy00.admin"), + hasEntry("grantedByRoleIdName", "test_customer#yyy:ADMIN"), + hasEntry("grantedRoleIdName", "test_package#yyy00:ADMIN"), hasEntry("granteeUserName", "pac-admin-yyy00@yyy.example.com") ) )) @@ -135,7 +133,6 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { } @Test - @Accepts({ "GRT:L(List)", "GRT:X(Access Control)" }) void packageAdmin_withoutAssumedRole_canViewPacketRelatedGrants() { RestAssured // @formatter:off .given() @@ -148,13 +145,13 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("grantedByRoleIdName", "test_customer#yyy.admin"), - hasEntry("grantedRoleIdName", "test_package#yyy00.admin"), + hasEntry("grantedByRoleIdName", "test_customer#yyy:ADMIN"), + hasEntry("grantedRoleIdName", "test_package#yyy00:ADMIN"), hasEntry("granteeUserName", "pac-admin-yyy00@yyy.example.com") ) )) - .body("[0].grantedByRoleIdName", is("test_customer#yyy.admin")) - .body("[0].grantedRoleIdName", is("test_package#yyy00.admin")) + .body("[0].grantedByRoleIdName", is("test_customer#yyy:ADMIN")) + .body("[0].grantedRoleIdName", is("test_package#yyy00:ADMIN")) .body("[0].granteeUserName", is("pac-admin-yyy00@yyy.example.com")); // @formatter:on } @@ -164,12 +161,11 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { class GetGrantById { @Test - @Accepts({ "GRT:R(Read)" }) void customerAdmin_withAssumedPacketAdminRole_canReadPacketAdminsGrantById() { // given final var givenCurrentUserAsPackageAdmin = new Subject("customer-admin@xxx.example.com"); final var givenGranteeUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); - final var givenGrantedRole = findRbacRoleByName("test_package#xxx00.admin"); + final var givenGrantedRole = getRbacRoleByName("test_package#xxx00:ADMIN"); // when final var grant = givenCurrentUserAsPackageAdmin.getGrantById() @@ -178,18 +174,17 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { // then grant.assertThat() .statusCode(200) - .body("grantedByRoleIdName", is("test_customer#xxx.admin")) - .body("grantedRoleIdName", is("test_package#xxx00.admin")) + .body("grantedByRoleIdName", is("test_customer#xxx:ADMIN")) + .body("grantedRoleIdName", is("test_package#xxx00:ADMIN")) .body("granteeUserName", is("pac-admin-xxx00@xxx.example.com")); } @Test - @Accepts({ "GRT:R(Read)" }) void packageAdmin_withoutAssumedRole_canReadItsOwnGrantById() { // given final var givenCurrentUserAsPackageAdmin = new Subject("pac-admin-xxx00@xxx.example.com"); final var givenGranteeUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); - final var givenGrantedRole = findRbacRoleByName("test_package#xxx00.admin"); + final var givenGrantedRole = getRbacRoleByName("test_package#xxx00:ADMIN"); // when final var grant = givenCurrentUserAsPackageAdmin.getGrantById() @@ -198,20 +193,19 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { // then grant.assertThat() .statusCode(200) - .body("grantedByRoleIdName", is("test_customer#xxx.admin")) - .body("grantedRoleIdName", is("test_package#xxx00.admin")) + .body("grantedByRoleIdName", is("test_customer#xxx:ADMIN")) + .body("grantedRoleIdName", is("test_package#xxx00:ADMIN")) .body("granteeUserName", is("pac-admin-xxx00@xxx.example.com")); } @Test - @Accepts({ "GRT:R(Read)", "GRT:X(Access Control)" }) void packageAdmin_withAssumedPackageAdmin_canStillReadItsOwnGrantById() { // given final var givenCurrentUserAsPackageAdmin = new Subject( "pac-admin-xxx00@xxx.example.com", - "test_package#xxx00.admin"); + "test_package#xxx00:ADMIN"); final var givenGranteeUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); - final var givenGrantedRole = findRbacRoleByName("test_package#xxx00.admin"); + final var givenGrantedRole = getRbacRoleByName("test_package#xxx00:ADMIN"); // when final var grant = givenCurrentUserAsPackageAdmin.getGrantById() @@ -220,21 +214,20 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { // then grant.assertThat() .statusCode(200) - .body("grantedByRoleIdName", is("test_customer#xxx.admin")) - .body("grantedRoleIdName", is("test_package#xxx00.admin")) + .body("grantedByRoleIdName", is("test_customer#xxx:ADMIN")) + .body("grantedRoleIdName", is("test_package#xxx00:ADMIN")) .body("granteeUserName", is("pac-admin-xxx00@xxx.example.com")); } @Test - @Accepts({ "GRT:R(Read)", "GRT:X(Access Control)" }) void packageAdmin_withAssumedPackageTenantRole_canNotReadItsOwnGrantByIdAnymore() { // given final var givenCurrentUserAsPackageAdmin = new Subject( "pac-admin-xxx00@xxx.example.com", - "test_package#xxx00.tenant"); + "test_package#xxx00:TENANT"); final var givenGranteeUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); - final var givenGrantedRole = findRbacRoleByName("test_package#xxx00.admin"); + final var givenGrantedRole = getRbacRoleByName("test_package#xxx00:ADMIN"); final var grant = givenCurrentUserAsPackageAdmin.getGrantById() .forGrantedRole(givenGrantedRole).toGranteeUser(givenGranteeUser); @@ -248,15 +241,14 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { class GrantRoleToUser { @Test - @Accepts({ "GRT:C(Create)" }) void packageAdmin_canGrantOwnPackageAdminRole_toArbitraryUser() { // given final var givenNewUser = createRBacUser(); - final var givenRoleToGrant = "test_package#xxx00.admin"; + final var givenRoleToGrant = "test_package#xxx00:ADMIN"; final var givenCurrentUserAsPackageAdmin = new Subject("pac-admin-xxx00@xxx.example.com", givenRoleToGrant); final var givenOwnPackageAdminRole = - findRbacRoleByName(givenCurrentUserAsPackageAdmin.assumedRole); + getRbacRoleByName(givenCurrentUserAsPackageAdmin.assumedRole); // when final var response = givenCurrentUserAsPackageAdmin @@ -266,26 +258,25 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { // then response.assertThat() .statusCode(201) - .body("grantedByRoleIdName", is("test_package#xxx00.admin")) + .body("grantedByRoleIdName", is("test_package#xxx00:ADMIN")) .body("assumed", is(true)) - .body("grantedRoleIdName", is("test_package#xxx00.admin")) + .body("grantedRoleIdName", is("test_package#xxx00:ADMIN")) .body("granteeUserName", is(givenNewUser.getName())); assertThat(findAllGrantsOf(givenCurrentUserAsPackageAdmin)) .extracting(RbacGrantEntity::toDisplay) - .contains("{ grant role " + givenOwnPackageAdminRole.getRoleName() + - " to user " + givenNewUser.getName() + - " by role " + givenRoleToGrant + " and assume }"); + .contains("{ grant role:" + givenOwnPackageAdminRole.getRoleName() + + " to user:" + givenNewUser.getName() + + " by role:" + givenRoleToGrant + " and assume }"); } @Test - @Accepts({ "GRT:C(Create)", "GRT:X(Access Control)" }) void packageAdmin_canNotGrantAlienPackageAdminRole_toArbitraryUser() { // given final var givenNewUser = createRBacUser(); - final var givenRoleToGrant = "test_package#xxx00.admin"; + final var givenRoleToGrant = "test_package#xxx00:ADMIN"; final var givenCurrentUserAsPackageAdmin = new Subject("pac-admin-xxx00@xxx.example.com", givenRoleToGrant); - final var givenAlienPackageAdminRole = findRbacRoleByName("test_package#yyy00.admin"); + final var givenAlienPackageAdminRole = getRbacRoleByName("test_package#yyy00:ADMIN"); // when final var result = givenCurrentUserAsPackageAdmin @@ -296,7 +287,7 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { result.assertThat() .statusCode(403) .body("message", containsString("Access to granted role")) - .body("message", containsString("forbidden for {test_package#xxx00.admin}")); + .body("message", containsString("forbidden for test_package#xxx00:ADMIN")); assertThat(findAllGrantsOf(givenCurrentUserAsPackageAdmin)) .extracting(RbacGrantEntity::getGranteeUserName) .doesNotContain(givenNewUser.getName()); @@ -307,15 +298,14 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { class RevokeRoleFromUser { @Test - @Accepts({ "GRT:D(Delete)" }) @Transactional(propagation = Propagation.NEVER) void packageAdmin_canRevokePackageAdminRole_grantedByPackageAdmin_fromArbitraryUser() { // given final var givenArbitraryUser = createRBacUser(); - final var givenRoleToGrant = "test_package#xxx00.admin"; + final var givenRoleToGrant = "test_package#xxx00:ADMIN"; final var givenCurrentUserAsPackageAdmin = new Subject("pac-admin-xxx00@xxx.example.com", givenRoleToGrant); - final var givenOwnPackageAdminRole = findRbacRoleByName("test_package#xxx00.admin"); + final var givenOwnPackageAdminRole = getRbacRoleByName("test_package#xxx00:ADMIN"); // and given an existing grant assumeCreated(givenCurrentUserAsPackageAdmin @@ -323,7 +313,7 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { .toUser(givenArbitraryUser)); assumeGrantExists( givenCurrentUserAsPackageAdmin, - "{ grant role %s to user %s by role %s and assume }".formatted( + "{ grant role:%s to user:%s by role:%s and assume }".formatted( givenOwnPackageAdminRole.getRoleName(), givenArbitraryUser.getName(), givenCurrentUserAsPackageAdmin.assumedRole)); @@ -502,13 +492,13 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", null); return rbacUserRepository.findByName(userName); - }).returnedValue(); + }).assertNotNull().returnedValue(); } - RbacRoleEntity findRbacRoleByName(final String roleName) { + RbacRoleEntity getRbacRoleByName(final String roleName) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", null); return rbacRoleRepository.findByRoleName(roleName); - }).returnedValue(); + }).assertNotNull().returnedValue(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntityUnitTest.java index eea18932..c0bd82cc 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntityUnitTest.java @@ -34,13 +34,13 @@ class RbacGrantEntityUnitTest { "GrantEE", UUID.randomUUID(), true, "ObjectTable", "ObjectId", UUID.randomUUID(), - RbacRoleType.admin); // @formatter:on + RbacRoleType.ADMIN); // @formatter:on // when final var display = entity.toDisplay(); // then - assertThat(display).isEqualTo("{ grant role GrantED to user GrantEE by role GrantER and assume }"); + assertThat(display).isEqualTo("{ grant role:GrantED to user:GrantEE by role:GrantER and assume }"); } @Test @@ -52,12 +52,12 @@ class RbacGrantEntityUnitTest { "GrantEE", UUID.randomUUID(), false, "ObjectTable", "ObjectId", UUID.randomUUID(), - RbacRoleType.owner); // @formatter:on + RbacRoleType.OWNER); // @formatter:on // when final var display = entity.toDisplay(); // then - assertThat(display).isEqualTo("{ grant role GrantED to user GrantEE by role GrantER }"); + assertThat(display).isEqualTo("{ grant role:GrantED to user:GrantEE by role:GrantER }"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java index 3b09e861..804a564e 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java @@ -1,12 +1,11 @@ package net.hostsharing.hsadminng.rbac.rbacgrant; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserEntity; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +22,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.List; import java.util.UUID; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @@ -58,7 +57,6 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { class FindAllGrantsOfUser { @Test - @Accepts({ "GRT:L(List)" }) public void packageAdmin_canViewItsRbacGrants() { // given context("pac-admin-xxx00@xxx.example.com", null); @@ -69,11 +67,10 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseRbacGrantsAreReturned( result, - "{ grant role test_package#xxx00.admin to user pac-admin-xxx00@xxx.example.com by role test_customer#xxx.admin and assume }"); + "{ grant role:test_package#xxx00:ADMIN to user:pac-admin-xxx00@xxx.example.com by role:test_customer#xxx:ADMIN and assume }"); } @Test - @Accepts({ "GRT:L(List)" }) public void customerAdmin_canViewItsRbacGrants() { // given context("customer-admin@xxx.example.com", null); @@ -84,17 +81,16 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseRbacGrantsAreReturned( result, - "{ grant role test_customer#xxx.admin to user customer-admin@xxx.example.com by role global#global.admin and assume }", - "{ grant role test_package#xxx00.admin to user pac-admin-xxx00@xxx.example.com by role test_customer#xxx.admin and assume }", - "{ grant role test_package#xxx01.admin to user pac-admin-xxx01@xxx.example.com by role test_customer#xxx.admin and assume }", - "{ grant role test_package#xxx02.admin to user pac-admin-xxx02@xxx.example.com by role test_customer#xxx.admin and assume }"); + "{ grant role:test_customer#xxx:ADMIN to user:customer-admin@xxx.example.com by role:test_customer#xxx:OWNER and assume }", + "{ grant role:test_package#xxx00:ADMIN to user:pac-admin-xxx00@xxx.example.com by role:test_customer#xxx:ADMIN and assume }", + "{ grant role:test_package#xxx01:ADMIN to user:pac-admin-xxx01@xxx.example.com by role:test_customer#xxx:ADMIN and assume }", + "{ grant role:test_package#xxx02:ADMIN to user:pac-admin-xxx02@xxx.example.com by role:test_customer#xxx:ADMIN and assume }"); } @Test - @Accepts({ "GRT:L(List)" }) public void customerAdmin_withAssumedRole_canOnlyViewRbacGrantsVisibleByAssumedRole() { // given: - context("customer-admin@xxx.example.com", "test_package#xxx00.admin"); + context("customer-admin@xxx.example.com", "test_package#xxx00:ADMIN"); // when final var result = rbacGrantRepository.findAll(); @@ -102,7 +98,7 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { // then exactlyTheseRbacGrantsAreReturned( result, - "{ grant role test_package#xxx00.admin to user pac-admin-xxx00@xxx.example.com by role test_customer#xxx.admin and assume }"); + "{ grant role:test_package#xxx00:ADMIN to user:pac-admin-xxx00@xxx.example.com by role:test_customer#xxx:ADMIN and assume }"); } } @@ -112,9 +108,9 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { @Test public void customerAdmin_canGrantOwnPackageAdminRole_toArbitraryUser() { // given - context("customer-admin@xxx.example.com", "test_customer#xxx.admin"); + context("customer-admin@xxx.example.com", "test_customer#xxx:ADMIN"); final var givenArbitraryUserUuid = rbacUserRepository.findByName("pac-admin-zzz00@zzz.example.com").getUuid(); - final var givenOwnPackageRoleUuid = rbacRoleRepository.findByRoleName("test_package#xxx00.admin").getUuid(); + final var givenOwnPackageRoleUuid = rbacRoleRepository.findByRoleName("test_package#xxx00:ADMIN").getUuid(); // when final var grant = RbacGrantEntity.builder() @@ -130,7 +126,7 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { assertThat(rbacGrantRepository.findAll()) .extracting(RbacGrantEntity::toDisplay) .contains( - "{ grant role test_package#xxx00.admin to user pac-admin-zzz00@zzz.example.com by role test_customer#xxx.admin and assume }"); + "{ grant role:test_package#xxx00:ADMIN to user:pac-admin-zzz00@zzz.example.com by role:test_customer#xxx:ADMIN and assume }"); } @Test @@ -143,14 +139,14 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { context("customer-admin@xxx.example.com", null); return new Given( createNewUser(), - rbacRoleRepository.findByRoleName("test_package#xxx00.owner").getUuid() + rbacRoleRepository.findByRoleName("test_package#xxx00:OWNER").getUuid() ); }).assumeSuccessful().returnedValue(); // when final var attempt = jpaAttempt.transacted(() -> { // now we try to use these uuids as a less privileged user - context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00.admin"); + context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00:ADMIN"); final var grant = RbacGrantEntity.builder() .granteeUserUuid(given.arbitraryUser.getUuid()) .grantedRoleUuid(given.packageOwnerRoleUuid) @@ -162,8 +158,8 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { // then attempt.assertExceptionWithRootCauseMessage( JpaSystemException.class, - "ERROR: [403] Access to granted role " + given.packageOwnerRoleUuid - + " forbidden for {test_package#xxx00.admin}"); + "ERROR: [403] Access to granted role test_package#xxx00:OWNER", + "forbidden for test_package#xxx00:ADMIN"); jpaAttempt.transacted(() -> { // finally, we use the new user to make sure, no roles were granted context(given.arbitraryUser.getName(), null); @@ -180,16 +176,16 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { public void customerAdmin_canRevokeSelfGrantedPackageAdminRole() { // given final var grant = create(grant() - .byUser("customer-admin@xxx.example.com").withAssumedRole("test_customer#xxx.admin") - .grantingRole("test_package#xxx00.admin").toUser("pac-admin-zzz00@zzz.example.com")); + .byUser("customer-admin@xxx.example.com").withAssumedRole("test_customer#xxx:ADMIN") + .grantingRole("test_package#xxx00:ADMIN").toUser("pac-admin-zzz00@zzz.example.com")); // when - context("customer-admin@xxx.example.com", "test_customer#xxx.admin"); + context("customer-admin@xxx.example.com", "test_customer#xxx:ADMIN"); final var revokeAttempt = attempt(em, () -> rbacGrantRepository.deleteByRbacGrantId(grant.getRbacGrantId())); // then - context("customer-admin@xxx.example.com", "test_customer#xxx.admin"); + context("customer-admin@xxx.example.com", "test_customer#xxx:ADMIN"); assertThat(revokeAttempt.caughtExceptionsRootCause()).isNull(); assertThat(rbacGrantRepository.findAll()) .extracting(RbacGrantEntity::getGranteeUserName) @@ -201,17 +197,17 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { // given final var newUser = createNewUserTransacted(); final var grant = create(grant() - .byUser("customer-admin@xxx.example.com").withAssumedRole("test_package#xxx00.admin") - .grantingRole("test_package#xxx00.admin").toUser(newUser.getName())); + .byUser("customer-admin@xxx.example.com").withAssumedRole("test_package#xxx00:ADMIN") + .grantingRole("test_package#xxx00:ADMIN").toUser(newUser.getName())); // when - context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00.admin"); + context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00:ADMIN"); final var revokeAttempt = attempt(em, () -> rbacGrantRepository.deleteByRbacGrantId(grant.getRbacGrantId())); // then assertThat(revokeAttempt.caughtExceptionsRootCause()).isNull(); - context("customer-admin@xxx.example.com", "test_customer#xxx.admin"); + context("customer-admin@xxx.example.com", "test_customer#xxx:ADMIN"); assertThat(rbacGrantRepository.findAll()) .extracting(RbacGrantEntity::getGranteeUserName) .doesNotContain("pac-admin-zzz00@zzz.example.com"); @@ -221,19 +217,19 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { public void packageAdmin_canNotRevokeOwnPackageAdminRoleGrantedByOwnerRoleOfThatPackage() { // given final var grant = create(grant() - .byUser("customer-admin@xxx.example.com").withAssumedRole("test_package#xxx00.owner") - .grantingRole("test_package#xxx00.admin").toUser("pac-admin-zzz00@zzz.example.com")); - final var grantedByRole = rbacRoleRepository.findByRoleName("test_package#xxx00.owner"); + .byUser("customer-admin@xxx.example.com").withAssumedRole("test_package#xxx00:OWNER") + .grantingRole("test_package#xxx00:ADMIN").toUser("pac-admin-zzz00@zzz.example.com")); + final var grantedByRole = rbacRoleRepository.findByRoleName("test_package#xxx00:OWNER"); // when - context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00.admin"); + context("pac-admin-xxx00@xxx.example.com", "test_package#xxx00:ADMIN"); final var revokeAttempt = attempt(em, () -> rbacGrantRepository.deleteByRbacGrantId(grant.getRbacGrantId())); // then revokeAttempt.assertExceptionWithRootCauseMessage( JpaSystemException.class, - "ERROR: [403] Revoking role created by %s is forbidden for {test_package#xxx00.admin}.".formatted( + "ERROR: [403] Revoking role created by %s is forbidden for {test_package#xxx00:ADMIN}.".formatted( grantedByRole.getUuid() )); } @@ -254,7 +250,7 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { assertThat(grantAttempt.caughtException()).isNull(); assertThat(rawRbacGrantRepository.findAll()) .extracting(RawRbacGrantEntity::toDisplay) - .contains("{ grant role %s to user %s by %s and assume }".formatted( + .contains("{ grant role:%s to user:%s by %s and assume }".formatted( with.grantedRole, with.granteeUserName, with.assumedRole )); diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java new file mode 100644 index 00000000..7f183ba3 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java @@ -0,0 +1,103 @@ +package net.hostsharing.hsadminng.rbac.rbacgrant; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService.Include; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.EnumSet; +import java.util.UUID; + +import static java.lang.String.join; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import( { Context.class, JpaAttempt.class, RbacGrantsDiagramService.class}) +class RbacGrantsDiagramServiceIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + RbacGrantsDiagramService grantsMermaidService; + + @MockBean + HttpServletRequest request; + + @Autowired + Context context; + + @Autowired + RbacGrantsDiagramService diagramService; + + TestInfo test; + + @BeforeEach + void init(TestInfo testInfo) { + this.test = testInfo; + } + + protected void context(final String currentUser, final String assumedRoles) { + context.define(test.getDisplayName(), null, currentUser, assumedRoles); + } + + protected void context(final String currentUser) { + context(currentUser, null); + } + + @Test + void allGrantsToCurrentUser() { + context("superuser-alex@hostsharing.net", "test_domain#xxx00-aaaa:OWNER"); + final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.TEST_ENTITIES)); + + assertThat(graph).isEqualTo(""" + flowchart TB + + role:test_domain#xxx00-aaaa:ADMIN --> role:test_package#xxx00:TENANT + role:test_domain#xxx00-aaaa:OWNER --> role:test_domain#xxx00-aaaa:ADMIN + role:test_domain#xxx00-aaaa:OWNER --> role:test_package#xxx00:TENANT + role:test_package#xxx00:TENANT --> role:test_customer#xxx:TENANT + """.trim()); + } + + @Test + void allGrantsToCurrentUserIncludingPermissions() { + context("superuser-alex@hostsharing.net", "test_domain#xxx00-aaaa:OWNER"); + final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.TEST_ENTITIES, Include.PERMISSIONS)); + + assertThat(graph).isEqualTo(""" + flowchart TB + + role:test_customer#xxx:TENANT --> perm:test_customer#xxx:SELECT + role:test_domain#xxx00-aaaa:ADMIN --> perm:test_domain#xxx00-aaaa:SELECT + role:test_domain#xxx00-aaaa:ADMIN --> role:test_package#xxx00:TENANT + role:test_domain#xxx00-aaaa:OWNER --> perm:test_domain#xxx00-aaaa:DELETE + role:test_domain#xxx00-aaaa:OWNER --> perm:test_domain#xxx00-aaaa:UPDATE + role:test_domain#xxx00-aaaa:OWNER --> role:test_domain#xxx00-aaaa:ADMIN + role:test_domain#xxx00-aaaa:OWNER --> role:test_package#xxx00:TENANT + role:test_package#xxx00:TENANT --> perm:test_package#xxx00:SELECT + role:test_package#xxx00:TENANT --> role:test_customer#xxx:TENANT + """.trim()); + } + + @Test + @Disabled // enable to generate from a real database + void print() throws IOException { + //context("superuser-alex@hostsharing.net", "hs_office_person#FirbySusan:ADMIN"); + context("superuser-alex@hostsharing.net"); + + //final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.NON_TEST_ENTITIES, Include.PERMISSIONS)); + + final var targetObject = (UUID) em.createNativeQuery("SELECT uuid FROM hs_office_coopassetstransaction WHERE reference='ref 1000101-1'").getSingleResult(); + final var graph = grantsMermaidService.allGrantsFrom(targetObject, "view", EnumSet.of(Include.USERS)); + + RbacGrantsDiagramService.writeToFile(join(";", context.getAssumedRoles()), graph, "doc/all-grants.md"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectEntity.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectEntity.java new file mode 100644 index 00000000..d4256e56 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectEntity.java @@ -0,0 +1,31 @@ +package net.hostsharing.hsadminng.rbac.rbacrole; + +import lombok.*; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.annotation.Immutable; + +import jakarta.persistence.*; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "rbacobject") // TODO: create view rbacobject_ev +@Getter +@Setter +@ToString +@Immutable +@NoArgsConstructor +@AllArgsConstructor +public class RawRbacObjectEntity { + + @Id + private UUID uuid; + + @Column(name="objecttable") + private String objectTable; + + @NotNull + public static List objectDisplaysOf(@NotNull final List roles) { + return roles.stream().map(e -> e.objectTable+ "#" + e.uuid).sorted().toList(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectRepository.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectRepository.java new file mode 100644 index 00000000..ab645316 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacObjectRepository.java @@ -0,0 +1,11 @@ +package net.hostsharing.hsadminng.rbac.rbacrole; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.UUID; + +public interface RawRbacObjectRepository extends Repository { + + List findAll(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacRoleEntity.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacRoleEntity.java index 88dd2667..e80f8ce6 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacRoleEntity.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RawRbacRoleEntity.java @@ -8,7 +8,6 @@ import org.springframework.data.annotation.Immutable; import jakarta.persistence.*; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; @Entity @Table(name = "rbacrole_ev") @@ -36,12 +35,13 @@ public class RawRbacRoleEntity { @Enumerated(EnumType.STRING) private RbacRoleType roleType; - @Formula("objectTable||'#'||objectIdName||'.'||roleType") + @Formula("objectTable||'#'||objectIdName||':'||roleType") private String roleName; @NotNull - public static List roleNamesOf(@NotNull final List roles) { - return roles.stream().map(RawRbacRoleEntity::getRoleName).collect(Collectors.toList()); + public static List distinctRoleNamesOf(@NotNull final List roles) { + // TODO: remove .distinct() once partner.person + partner.contract are removed + return roles.stream().map(RawRbacRoleEntity::getRoleName).sorted().distinct().toList(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java index 5de93348..5f20b0ab 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java @@ -4,7 +4,6 @@ import io.restassured.RestAssured; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository; -import net.hostsharing.test.Accepts; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -16,7 +15,6 @@ import static org.hamcrest.Matchers.*; webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = HsadminNgApplication.class ) -@Accepts({ "ROL:*:S:Schema" }) class RbacRoleControllerAcceptanceTest { @LocalServerPort @@ -32,7 +30,6 @@ class RbacRoleControllerAcceptanceTest { RbacRoleRepository rbacRoleRepository; @Test - @Accepts({ "ROL:L(List)" }) void globalAdmin_withoutAssumedRole_canViewAllRoles() { // @formatter:off @@ -45,27 +42,26 @@ class RbacRoleControllerAcceptanceTest { .then().assertThat() .statusCode(200) .contentType("application/json") - .body("", hasItem(hasEntry("roleName", "test_customer#xxx.admin"))) - .body("", hasItem(hasEntry("roleName", "test_customer#xxx.owner"))) - .body("", hasItem(hasEntry("roleName", "test_customer#xxx.tenant"))) + .body("", hasItem(hasEntry("roleName", "test_customer#xxx:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_customer#xxx:OWNER"))) + .body("", hasItem(hasEntry("roleName", "test_customer#xxx:TENANT"))) // ... - .body("", hasItem(hasEntry("roleName", "global#global.admin"))) - .body("", hasItem(hasEntry("roleName", "test_customer#yyy.admin"))) - .body("", hasItem(hasEntry("roleName", "test_package#yyy00.admin"))) - .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa.owner"))) + .body("", hasItem(hasEntry("roleName", "global#global:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_customer#yyy:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_package#yyy00:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa:OWNER"))) .body( "size()", greaterThanOrEqualTo(73)); // increases with new test data // @formatter:on } @Test - @Accepts({ "ROL:L(List)", "ROL:X(Access Control)" }) void globalAdmin_withAssumedPackageAdminRole_canViewPackageAdminRoles() { // @formatter:off RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_package#yyy00.admin") + .header("assumed-roles", "test_package#yyy00:ADMIN") .port(port) .when() .get("http://localhost/api/rbac/roles") @@ -75,24 +71,23 @@ class RbacRoleControllerAcceptanceTest { .statusCode(200) .contentType("application/json") - .body("", hasItem(hasEntry("roleName", "test_customer#yyy.tenant"))) - .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa.owner"))) - .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa.admin"))) - .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaab.owner"))) - .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaab.admin"))) - .body("", hasItem(hasEntry("roleName", "test_package#yyy00.admin"))) - .body("", hasItem(hasEntry("roleName", "test_package#yyy00.tenant"))) + .body("", hasItem(hasEntry("roleName", "test_customer#yyy:TENANT"))) + .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa:OWNER"))) + .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaab:OWNER"))) + .body("", hasItem(hasEntry("roleName", "test_domain#yyy00-aaab:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_package#yyy00:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_package#yyy00:TENANT"))) - .body("", not(hasItem(hasEntry("roleName", "test_customer#xxx.tenant")))) - .body("", not(hasItem(hasEntry("roleName", "test_domain#xxx00-aaaa.admin")))) - .body("", not(hasItem(hasEntry("roleName", "test_package#xxx00.admin")))) - .body("", not(hasItem(hasEntry("roleName", "test_package#xxx00.tenant")))) + .body("", not(hasItem(hasEntry("roleName", "test_customer#xxx:TENANT")))) + .body("", not(hasItem(hasEntry("roleName", "test_domain#xxx00-aaaa:ADMIN")))) + .body("", not(hasItem(hasEntry("roleName", "test_package#xxx00:ADMIN")))) + .body("", not(hasItem(hasEntry("roleName", "test_package#xxx00:TENANT")))) ; // @formatter:on } @Test - @Accepts({ "ROL:L(List)", "ROL:X(Access Control)" }) void packageAdmin_withoutAssumedRole_canViewPackageAdminRoles() { // @formatter:off @@ -106,15 +101,15 @@ class RbacRoleControllerAcceptanceTest { .statusCode(200) .contentType("application/json") - .body("", hasItem(hasEntry("roleName", "test_customer#zzz.tenant"))) - .body("", hasItem(hasEntry("roleName", "test_domain#zzz00-aaaa.admin"))) - .body("", hasItem(hasEntry("roleName", "test_package#zzz00.admin"))) - .body("", hasItem(hasEntry("roleName", "test_package#zzz00.tenant"))) + .body("", hasItem(hasEntry("roleName", "test_customer#zzz:TENANT"))) + .body("", hasItem(hasEntry("roleName", "test_domain#zzz00-aaaa:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_package#zzz00:ADMIN"))) + .body("", hasItem(hasEntry("roleName", "test_package#zzz00:TENANT"))) - .body("", not(hasItem(hasEntry("roleName", "test_customer#yyy.tenant")))) - .body("", not(hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa.admin")))) - .body("", not(hasItem(hasEntry("roleName", "test_package#yyy00.admin")))) - .body("", not(hasItem(hasEntry("roleName", "test_package#yyy00.tenant")))); + .body("", not(hasItem(hasEntry("roleName", "test_customer#yyy:TENANT")))) + .body("", not(hasItem(hasEntry("roleName", "test_domain#yyy00-aaaa:ADMIN")))) + .body("", not(hasItem(hasEntry("roleName", "test_package#yyy00:ADMIN")))) + .body("", not(hasItem(hasEntry("roleName", "test_package#yyy00:TENANT")))); // @formatter:on } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerRestTest.java index c10a9cbc..44b3885e 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerRestTest.java @@ -73,9 +73,9 @@ class RbacRoleControllerRestTest { // then .andExpect(status().isOk()) .andExpect(jsonPath("$", hasSize(3))) - .andExpect(jsonPath("$[0].roleName", is("global#global.admin"))) - .andExpect(jsonPath("$[1].roleName", is("test_customer#xxx.owner"))) - .andExpect(jsonPath("$[2].roleName", is("test_customer#xxx.admin"))) + .andExpect(jsonPath("$[0].roleName", is("global#global:ADMIN"))) + .andExpect(jsonPath("$[1].roleName", is("test_customer#xxx:OWNER"))) + .andExpect(jsonPath("$[2].roleName", is("test_customer#xxx:ADMIN"))) .andExpect(jsonPath("$[2].uuid", is(customerXxxAdmin.getUuid().toString()))) .andExpect(jsonPath("$[2].objectUuid", is(customerXxxAdmin.getObjectUuid().toString()))) .andExpect(jsonPath("$[2].objectTable", is(customerXxxAdmin.getObjectTable().toString()))) diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java index 197e0bc0..e7a28261 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java @@ -1,8 +1,8 @@ package net.hostsharing.hsadminng.rbac.rbacrole; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,7 +15,7 @@ import jakarta.persistence.EntityManager; import jakarta.servlet.http.HttpServletRequest; import java.util.List; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @@ -39,19 +39,19 @@ class RbacRoleRepositoryIntegrationTest { private static final String[] ALL_TEST_DATA_ROLES = Array.of( // @formatter:off - "global#global.admin", - "test_customer#xxx.admin", "test_customer#xxx.owner", "test_customer#xxx.tenant", - "test_package#xxx00.admin", "test_package#xxx00.owner", "test_package#xxx00.tenant", - "test_package#xxx01.admin", "test_package#xxx01.owner", "test_package#xxx01.tenant", - "test_package#xxx02.admin", "test_package#xxx02.owner", "test_package#xxx02.tenant", - "test_customer#yyy.admin", "test_customer#yyy.owner", "test_customer#yyy.tenant", - "test_package#yyy00.admin", "test_package#yyy00.owner", "test_package#yyy00.tenant", - "test_package#yyy01.admin", "test_package#yyy01.owner", "test_package#yyy01.tenant", - "test_package#yyy02.admin", "test_package#yyy02.owner", "test_package#yyy02.tenant", - "test_customer#zzz.admin", "test_customer#zzz.owner", "test_customer#zzz.tenant", - "test_package#zzz00.admin", "test_package#zzz00.owner", "test_package#zzz00.tenant", - "test_package#zzz01.admin", "test_package#zzz01.owner", "test_package#zzz01.tenant", - "test_package#zzz02.admin", "test_package#zzz02.owner", "test_package#zzz02.tenant" + "global#global:ADMIN", + "test_customer#xxx:ADMIN", "test_customer#xxx:OWNER", "test_customer#xxx:TENANT", + "test_package#xxx00:ADMIN", "test_package#xxx00:OWNER", "test_package#xxx00:TENANT", + "test_package#xxx01:ADMIN", "test_package#xxx01:OWNER", "test_package#xxx01:TENANT", + "test_package#xxx02:ADMIN", "test_package#xxx02:OWNER", "test_package#xxx02:TENANT", + "test_customer#yyy:ADMIN", "test_customer#yyy:OWNER", "test_customer#yyy:TENANT", + "test_package#yyy00:ADMIN", "test_package#yyy00:OWNER", "test_package#yyy00:TENANT", + "test_package#yyy01:ADMIN", "test_package#yyy01:OWNER", "test_package#yyy01:TENANT", + "test_package#yyy02:ADMIN", "test_package#yyy02:OWNER", "test_package#yyy02:TENANT", + "test_customer#zzz:ADMIN", "test_customer#zzz:OWNER", "test_customer#zzz:TENANT", + "test_package#zzz00:ADMIN", "test_package#zzz00:OWNER", "test_package#zzz00:TENANT", + "test_package#zzz01:ADMIN", "test_package#zzz01:OWNER", "test_package#zzz01:TENANT", + "test_package#zzz02:ADMIN", "test_package#zzz02:OWNER", "test_package#zzz02:TENANT" // @formatter:on ); @@ -70,7 +70,7 @@ class RbacRoleRepositoryIntegrationTest { @Test public void globalAdmin_withAssumedglobalAdminRole_canViewAllRbacRoles() { given: - context.define("superuser-alex@hostsharing.net", "global#global.admin"); + context.define("superuser-alex@hostsharing.net", "global#global:ADMIN"); // when final var result = rbacRoleRepository.findAll(); @@ -91,49 +91,49 @@ class RbacRoleRepositoryIntegrationTest { allTheseRbacRolesAreReturned( result, // @formatter:off - "test_customer#xxx.admin", - "test_customer#xxx.tenant", - "test_package#xxx00.admin", - "test_package#xxx00.owner", - "test_package#xxx00.tenant", - "test_package#xxx01.admin", - "test_package#xxx01.owner", - "test_package#xxx01.tenant", + "test_customer#xxx:ADMIN", + "test_customer#xxx:TENANT", + "test_package#xxx00:ADMIN", + "test_package#xxx00:OWNER", + "test_package#xxx00:TENANT", + "test_package#xxx01:ADMIN", + "test_package#xxx01:OWNER", + "test_package#xxx01:TENANT", // ... - "test_domain#xxx00-aaaa.admin", - "test_domain#xxx00-aaaa.owner", + "test_domain#xxx00-aaaa:ADMIN", + "test_domain#xxx00-aaaa:OWNER", // .. - "test_domain#xxx01-aaab.admin", - "test_domain#xxx01-aaab.owner" + "test_domain#xxx01-aaab:ADMIN", + "test_domain#xxx01-aaab:OWNER" // @formatter:on ); noneOfTheseRbacRolesIsReturned( result, // @formatter:off - "global#global.admin", - "test_customer#xxx.owner", - "test_package#yyy00.admin", - "test_package#yyy00.owner", - "test_package#yyy00.tenant" + "global#global:ADMIN", + "test_customer#xxx:OWNER", + "test_package#yyy00:ADMIN", + "test_package#yyy00:OWNER", + "test_package#yyy00:TENANT" // @formatter:on ); } @Test public void customerAdmin_withAssumedOwnedPackageAdminRole_canViewOnlyItsOwnRbacRole() { - context.define("customer-admin@xxx.example.com", "test_package#xxx00.admin"); + context.define("customer-admin@xxx.example.com", "test_package#xxx00:ADMIN"); final var result = rbacRoleRepository.findAll(); exactlyTheseRbacRolesAreReturned( result, - "test_customer#xxx.tenant", - "test_package#xxx00.admin", - "test_package#xxx00.tenant", - "test_domain#xxx00-aaaa.admin", - "test_domain#xxx00-aaaa.owner", - "test_domain#xxx00-aaab.admin", - "test_domain#xxx00-aaab.owner"); + "test_customer#xxx:TENANT", + "test_package#xxx00:ADMIN", + "test_package#xxx00:TENANT", + "test_domain#xxx00-aaaa:ADMIN", + "test_domain#xxx00-aaaa:OWNER", + "test_domain#xxx00-aaab:ADMIN", + "test_domain#xxx00-aaab:OWNER"); } @Test @@ -157,19 +157,19 @@ class RbacRoleRepositoryIntegrationTest { void customerAdmin_withoutAssumedRole_canFindItsOwnRolesByName() { context.define("customer-admin@xxx.example.com"); - final var result = rbacRoleRepository.findByRoleName("test_customer#xxx.admin"); + final var result = rbacRoleRepository.findByRoleName("test_customer#xxx:ADMIN"); assertThat(result).isNotNull(); assertThat(result.getObjectTable()).isEqualTo("test_customer"); assertThat(result.getObjectIdName()).isEqualTo("xxx"); - assertThat(result.getRoleType()).isEqualTo(RbacRoleType.admin); + assertThat(result.getRoleType()).isEqualTo(RbacRoleType.ADMIN); } @Test void customerAdmin_withoutAssumedRole_canNotFindAlienRolesByName() { context.define("customer-admin@xxx.example.com"); - final var result = rbacRoleRepository.findByRoleName("test_customer#bbb.admin"); + final var result = rbacRoleRepository.findByRoleName("test_customer#bbb:ADMIN"); assertThat(result).isNull(); } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/TestRbacRole.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/TestRbacRole.java index 652679f3..73e30a1b 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/TestRbacRole.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/TestRbacRole.java @@ -4,11 +4,11 @@ import static java.util.UUID.randomUUID; public class TestRbacRole { - public static final RbacRoleEntity hostmasterRole = rbacRole("global", "global", RbacRoleType.admin); - static final RbacRoleEntity customerXxxOwner = rbacRole("test_customer", "xxx", RbacRoleType.owner); - static final RbacRoleEntity customerXxxAdmin = rbacRole("test_customer", "xxx", RbacRoleType.admin); + public static final RbacRoleEntity hostmasterRole = rbacRole("global", "global", RbacRoleType.ADMIN); + static final RbacRoleEntity customerXxxOwner = rbacRole("test_customer", "xxx", RbacRoleType.OWNER); + static final RbacRoleEntity customerXxxAdmin = rbacRole("test_customer", "xxx", RbacRoleType.ADMIN); static public RbacRoleEntity rbacRole(final String objectTable, final String objectIdName, final RbacRoleType roleType) { - return new RbacRoleEntity(randomUUID(), randomUUID(), objectTable, objectIdName, roleType, objectTable+'#'+objectIdName+'.'+roleType); + return new RbacRoleEntity(randomUUID(), randomUUID(), objectTable, objectIdName, roleType, objectTable+'#'+objectIdName+':'+roleType); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java index b13bcb76..601fadad 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java @@ -4,8 +4,7 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.Accepts; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,7 +40,6 @@ class RbacUserControllerAcceptanceTest { class CreateRbacUser { @Test - @Accepts({ "USR:C(Create)", "USR:X(Access Control)" }) void anybody_canCreateANewUser() { // @formatter:off @@ -77,7 +75,6 @@ class RbacUserControllerAcceptanceTest { class GetRbacUser { @Test - @Accepts({ "USR:R(Read)" }) void globalAdmin_withoutAssumedRole_canGetArbitraryUser() { final var givenUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); @@ -96,7 +93,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:R(Read)", "USR:X(Access Control)" }) void globalAdmin_withAssumedCustomerAdminRole_canGetUserWithinInItsRealm() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -104,7 +100,7 @@ class RbacUserControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#yyy.admin") + .header("assumed-roles", "test_customer#yyy:ADMIN") .port(port) .when() .get("http://localhost/api/rbac/users/" + givenUser.getUuid()) @@ -116,7 +112,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:R(Read)", "USR:X(Access Control)" }) void customerAdmin_withoutAssumedRole_canGetUserWithinInItsRealm() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -135,7 +130,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:R(Read)", "USR:X(Access Control)" }) void customerAdmin_withoutAssumedRole_canNotGetUserOutsideOfItsRealm() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -156,7 +150,6 @@ class RbacUserControllerAcceptanceTest { class ListRbacUsers { @Test - @Accepts({ "USR:L(List)" }) void globalAdmin_withoutAssumedRole_canViewAllUsers() { // @formatter:off @@ -182,7 +175,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:F(Filter)" }) void globalAdmin_withoutAssumedRole_canViewAllUsersByName() { // @formatter:off @@ -203,14 +195,13 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:L(List)", "USR:X(Access Control)" }) void globalAdmin_withAssumedCustomerAdminRole_canViewUsersInItsRealm() { // @formatter:off RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#yyy.admin") + .header("assumed-roles", "test_customer#yyy:ADMIN") .port(port) .when() .get("http://localhost/api/rbac/users") @@ -226,7 +217,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:L(List)", "USR:X(Access Control)" }) void customerAdmin_withoutAssumedRole_canViewUsersInItsRealm() { // @formatter:off @@ -248,7 +238,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:L(List)", "USR:X(Access Control)" }) void packetAdmin_withoutAssumedRole_canViewAllUsersOfItsPackage() { // @formatter:off @@ -271,7 +260,6 @@ class RbacUserControllerAcceptanceTest { class ListRbacUserPermissions { @Test - @Accepts({ "PRM:L(List)" }) void globalAdmin_withoutAssumedRole_canViewArbitraryUsersPermissions() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -287,25 +275,20 @@ class RbacUserControllerAcceptanceTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("roleName", "test_customer#yyy.tenant"), - hasEntry("op", "view")) + hasEntry("roleName", "test_customer#yyy:TENANT"), + hasEntry("op", "SELECT")) )) .body("", hasItem( allOf( - hasEntry("roleName", "test_package#yyy00.admin"), - hasEntry("op", "add-domain")) + hasEntry("roleName", "test_domain#yyy00-aaaa:OWNER"), + hasEntry("op", "DELETE")) )) - .body("", hasItem( - allOf( - hasEntry("roleName", "test_domain#yyy00-aaaa.owner"), - hasEntry("op", "*")) - )) - .body("size()", is(7)); + // actual content tested in integration test, so this is enough for here: + .body("size()", greaterThanOrEqualTo(6)); // @formatter:on } @Test - @Accepts({ "PRM:L(List)" }) void globalAdmin_withAssumedCustomerAdminRole_canViewArbitraryUsersPermissions() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -313,7 +296,7 @@ class RbacUserControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_package#yyy00.admin") + .header("assumed-roles", "test_customer#yyy:ADMIN") .port(port) .when() .get("http://localhost/api/rbac/users/" + givenUser.getUuid() + "/permissions") @@ -322,25 +305,20 @@ class RbacUserControllerAcceptanceTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("roleName", "test_customer#yyy.tenant"), - hasEntry("op", "view")) + hasEntry("roleName", "test_customer#yyy:TENANT"), + hasEntry("op", "SELECT")) )) .body("", hasItem( allOf( - hasEntry("roleName", "test_package#yyy00.admin"), - hasEntry("op", "add-domain")) + hasEntry("roleName", "test_domain#yyy00-aaaa:OWNER"), + hasEntry("op", "DELETE")) )) - .body("", hasItem( - allOf( - hasEntry("roleName", "test_domain#yyy00-aaaa.owner"), - hasEntry("op", "*")) - )) - .body("size()", is(7)); + // actual content tested in integration test, so this is enough for here: + .body("size()", greaterThanOrEqualTo(6)); // @formatter:on } @Test - @Accepts({ "PRM:L(List)" }) void packageAdmin_withoutAssumedRole_canViewPermissionsOfUsersInItsRealm() { final var givenUser = findRbacUserByName("pac-admin-yyy00@yyy.example.com"); @@ -356,25 +334,20 @@ class RbacUserControllerAcceptanceTest { .contentType("application/json") .body("", hasItem( allOf( - hasEntry("roleName", "test_customer#yyy.tenant"), - hasEntry("op", "view")) + hasEntry("roleName", "test_customer#yyy:TENANT"), + hasEntry("op", "SELECT")) )) .body("", hasItem( allOf( - hasEntry("roleName", "test_package#yyy00.admin"), - hasEntry("op", "add-domain")) + hasEntry("roleName", "test_domain#yyy00-aaaa:OWNER"), + hasEntry("op", "DELETE")) )) - .body("", hasItem( - allOf( - hasEntry("roleName", "test_domain#yyy00-aaaa.owner"), - hasEntry("op", "*")) - )) - .body("size()", is(7)); + // actual content tested in integration test, so this is enough for here: + .body("size()", greaterThanOrEqualTo(6)); // @formatter:on } @Test - @Accepts({ "PRM:L(List)" }) void packageAdmin_canViewPermissionsOfUsersOutsideOfItsRealm() { final var givenUser = findRbacUserByName("pac-admin-xxx00@xxx.example.com"); @@ -397,7 +370,6 @@ class RbacUserControllerAcceptanceTest { class DeleteRbacUser { @Test - @Accepts({ "USR:D(Create)" }) void anybody_canDeleteTheirOwnUser() { // given @@ -419,7 +391,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:D(Create)", "USR:X(Access Control)" }) void customerAdmin_canNotDeleteOtherUser() { // given @@ -442,7 +413,6 @@ class RbacUserControllerAcceptanceTest { } @Test - @Accepts({ "USR:D(Create)", "USR:X(Access Control)" }) void globalAdmin_canDeleteArbitraryUser() { // given diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java index 6beec689..6e59f38a 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java @@ -21,7 +21,7 @@ import jakarta.persistence.SynchronizationType; import java.util.Map; import java.util.UUID; -import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java index ea0a3109..be6377a0 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java @@ -1,9 +1,9 @@ package net.hostsharing.hsadminng.rbac.rbacuser; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; -import net.hostsharing.test.Array; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,7 +20,8 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.List; import java.util.UUID; -import static net.hostsharing.test.JpaAttempt.attempt; +import static java.util.Comparator.comparing; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @@ -115,7 +116,7 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { @Test public void globalAdmin_withAssumedglobalAdminRole_canViewAllRbacUsers() { given: - context("superuser-alex@hostsharing.net", "global#global.admin"); + context("superuser-alex@hostsharing.net", "global#global:ADMIN"); // when final var result = rbacUserRepository.findByOptionalNameLike(null); @@ -127,7 +128,7 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { @Test public void globalAdmin_withAssumedCustomerAdminRole_canViewOnlyUsersHavingRolesInThatCustomersRealm() { given: - context("superuser-alex@hostsharing.net", "test_customer#xxx.admin"); + context("superuser-alex@hostsharing.net", "test_customer#xxx:ADMIN"); // when final var result = rbacUserRepository.findByOptionalNameLike(null); @@ -158,7 +159,7 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { @Test public void customerAdmin_withAssumedOwnedPackageAdminRole_canViewOnlyUsersHavingRolesInThatPackage() { - context("customer-admin@xxx.example.com", "test_package#xxx00.admin"); + context("customer-admin@xxx.example.com", "test_package#xxx00:ADMIN"); final var result = rbacUserRepository.findByOptionalNameLike(null); @@ -181,50 +182,48 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { private static final String[] ALL_USER_PERMISSIONS = Array.of( // @formatter:off - "global#global.admin -> global#global: add-customer", + "test_customer#xxx:ADMIN -> test_customer#xxx: SELECT", + "test_customer#xxx:OWNER -> test_customer#xxx: DELETE", + "test_customer#xxx:TENANT -> test_customer#xxx: SELECT", + "test_customer#xxx:ADMIN -> test_customer#xxx: INSERT:test_package", + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:TENANT -> test_package#xxx00: SELECT", + "test_package#xxx01:ADMIN -> test_package#xxx01: INSERT:test_domain", + "test_package#xxx01:ADMIN -> test_package#xxx01: INSERT:test_domain", + "test_package#xxx01:TENANT -> test_package#xxx01: SELECT", + "test_package#xxx02:ADMIN -> test_package#xxx02: INSERT:test_domain", + "test_package#xxx02:ADMIN -> test_package#xxx02: INSERT:test_domain", + "test_package#xxx02:TENANT -> test_package#xxx02: SELECT", - "test_customer#xxx.admin -> test_customer#xxx: add-package", - "test_customer#xxx.admin -> test_customer#xxx: view", - "test_customer#xxx.owner -> test_customer#xxx: *", - "test_customer#xxx.tenant -> test_customer#xxx: view", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.tenant -> test_package#xxx00: view", - "test_package#xxx01.admin -> test_package#xxx01: add-domain", - "test_package#xxx01.admin -> test_package#xxx01: add-domain", - "test_package#xxx01.tenant -> test_package#xxx01: view", - "test_package#xxx02.admin -> test_package#xxx02: add-domain", - "test_package#xxx02.admin -> test_package#xxx02: add-domain", - "test_package#xxx02.tenant -> test_package#xxx02: view", + "test_customer#yyy:ADMIN -> test_customer#yyy: SELECT", + "test_customer#yyy:OWNER -> test_customer#yyy: DELETE", + "test_customer#yyy:TENANT -> test_customer#yyy: SELECT", + "test_customer#yyy:ADMIN -> test_customer#yyy: INSERT:test_package", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:TENANT -> test_package#yyy00: SELECT", + "test_package#yyy01:ADMIN -> test_package#yyy01: INSERT:test_domain", + "test_package#yyy01:ADMIN -> test_package#yyy01: INSERT:test_domain", + "test_package#yyy01:TENANT -> test_package#yyy01: SELECT", + "test_package#yyy02:ADMIN -> test_package#yyy02: INSERT:test_domain", + "test_package#yyy02:ADMIN -> test_package#yyy02: INSERT:test_domain", + "test_package#yyy02:TENANT -> test_package#yyy02: SELECT", - "test_customer#yyy.admin -> test_customer#yyy: add-package", - "test_customer#yyy.admin -> test_customer#yyy: view", - "test_customer#yyy.owner -> test_customer#yyy: *", - "test_customer#yyy.tenant -> test_customer#yyy: view", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.tenant -> test_package#yyy00: view", - "test_package#yyy01.admin -> test_package#yyy01: add-domain", - "test_package#yyy01.admin -> test_package#yyy01: add-domain", - "test_package#yyy01.tenant -> test_package#yyy01: view", - "test_package#yyy02.admin -> test_package#yyy02: add-domain", - "test_package#yyy02.admin -> test_package#yyy02: add-domain", - "test_package#yyy02.tenant -> test_package#yyy02: view", - - "test_customer#zzz.admin -> test_customer#zzz: add-package", - "test_customer#zzz.admin -> test_customer#zzz: view", - "test_customer#zzz.owner -> test_customer#zzz: *", - "test_customer#zzz.tenant -> test_customer#zzz: view", - "test_package#zzz00.admin -> test_package#zzz00: add-domain", - "test_package#zzz00.admin -> test_package#zzz00: add-domain", - "test_package#zzz00.tenant -> test_package#zzz00: view", - "test_package#zzz01.admin -> test_package#zzz01: add-domain", - "test_package#zzz01.admin -> test_package#zzz01: add-domain", - "test_package#zzz01.tenant -> test_package#zzz01: view", - "test_package#zzz02.admin -> test_package#zzz02: add-domain", - "test_package#zzz02.admin -> test_package#zzz02: add-domain", - "test_package#zzz02.tenant -> test_package#zzz02: view" - // @formatter:on + "test_customer#zzz:ADMIN -> test_customer#zzz: SELECT", + "test_customer#zzz:OWNER -> test_customer#zzz: DELETE", + "test_customer#zzz:TENANT -> test_customer#zzz: SELECT", + "test_customer#zzz:ADMIN -> test_customer#zzz: INSERT:test_package", + "test_package#zzz00:ADMIN -> test_package#zzz00: INSERT:test_domain", + "test_package#zzz00:ADMIN -> test_package#zzz00: INSERT:test_domain", + "test_package#zzz00:TENANT -> test_package#zzz00: SELECT", + "test_package#zzz01:ADMIN -> test_package#zzz01: INSERT:test_domain", + "test_package#zzz01:ADMIN -> test_package#zzz01: INSERT:test_domain", + "test_package#zzz01:TENANT -> test_package#zzz01: SELECT", + "test_package#zzz02:ADMIN -> test_package#zzz02: INSERT:test_domain", + "test_package#zzz02:ADMIN -> test_package#zzz02: INSERT:test_domain", + "test_package#zzz02:TENANT -> test_package#zzz02: SELECT" + // @formatter:on ); @Test @@ -233,7 +232,9 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net"); // when - final var result = rbacUserRepository.findPermissionsOfUserByUuid(userUUID("superuser-alex@hostsharing.net")); + final var result = rbacUserRepository.findPermissionsOfUserByUuid(userUUID("superuser-fran@hostsharing.net")) + .stream().filter(p -> p.getObjectTable().contains("test_")) + .sorted(comparing(RbacUserPermission::toString)).toList(); // then allTheseRbacPermissionsAreReturned(result, ALL_USER_PERMISSIONS); @@ -251,32 +252,32 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { allTheseRbacPermissionsAreReturned( result, // @formatter:off - "test_customer#xxx.admin -> test_customer#xxx: add-package", - "test_customer#xxx.admin -> test_customer#xxx: view", - "test_customer#xxx.tenant -> test_customer#xxx: view", + "test_customer#xxx:ADMIN -> test_customer#xxx: INSERT:test_package", + "test_customer#xxx:ADMIN -> test_customer#xxx: SELECT", + "test_customer#xxx:TENANT -> test_customer#xxx: SELECT", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.tenant -> test_package#xxx00: view", - "test_domain#xxx00-aaaa.owner -> test_domain#xxx00-aaaa: *", + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:TENANT -> test_package#xxx00: SELECT", + "test_domain#xxx00-aaaa:OWNER -> test_domain#xxx00-aaaa: DELETE", - "test_package#xxx01.admin -> test_package#xxx01: add-domain", - "test_package#xxx01.admin -> test_package#xxx01: add-domain", - "test_package#xxx01.tenant -> test_package#xxx01: view", - "test_domain#xxx01-aaaa.owner -> test_domain#xxx01-aaaa: *", + "test_package#xxx01:ADMIN -> test_package#xxx01: INSERT:test_domain", + "test_package#xxx01:ADMIN -> test_package#xxx01: INSERT:test_domain", + "test_package#xxx01:TENANT -> test_package#xxx01: SELECT", + "test_domain#xxx01-aaaa:OWNER -> test_domain#xxx01-aaaa: DELETE", - "test_package#xxx02.admin -> test_package#xxx02: add-domain", - "test_package#xxx02.admin -> test_package#xxx02: add-domain", - "test_package#xxx02.tenant -> test_package#xxx02: view", - "test_domain#xxx02-aaaa.owner -> test_domain#xxx02-aaaa: *" + "test_package#xxx02:ADMIN -> test_package#xxx02: INSERT:test_domain", + "test_package#xxx02:ADMIN -> test_package#xxx02: INSERT:test_domain", + "test_package#xxx02:TENANT -> test_package#xxx02: SELECT", + "test_domain#xxx02-aaaa:OWNER -> test_domain#xxx02-aaaa: DELETE" // @formatter:on ); noneOfTheseRbacPermissionsAreReturned( result, // @formatter:off - "test_customer#yyy.admin -> test_customer#yyy: add-package", - "test_customer#yyy.admin -> test_customer#yyy: view", - "test_customer#yyy.tenant -> test_customer#yyy: view" + "test_customer#yyy:ADMIN -> test_customer#yyy: INSERT:test_package", + "test_customer#yyy:ADMIN -> test_customer#yyy: SELECT", + "test_customer#yyy:TENANT -> test_customer#yyy: SELECT" // @formatter:on ); } @@ -311,26 +312,26 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { allTheseRbacPermissionsAreReturned( result, // @formatter:off - "test_customer#xxx.tenant -> test_customer#xxx: view", - // "test_customer#xxx.admin -> test_customer#xxx: view" - Not permissions through the customer admin! - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.tenant -> test_package#xxx00: view", - "test_domain#xxx00-aaaa.owner -> test_domain#xxx00-aaaa: *", - "test_domain#xxx00-aaab.owner -> test_domain#xxx00-aaab: *" + "test_customer#xxx:TENANT -> test_customer#xxx: SELECT", + // "test_customer#xxx:ADMIN -> test_customer#xxx: view" - Not permissions through the customer admin! + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:TENANT -> test_package#xxx00: SELECT", + "test_domain#xxx00-aaaa:OWNER -> test_domain#xxx00-aaaa: DELETE", + "test_domain#xxx00-aaab:OWNER -> test_domain#xxx00-aaab: DELETE" // @formatter:on ); noneOfTheseRbacPermissionsAreReturned( result, // @formatter:off - "test_customer#yyy.admin -> test_customer#yyy: add-package", - "test_customer#yyy.admin -> test_customer#yyy: view", - "test_customer#yyy.tenant -> test_customer#yyy: view", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.tenant -> test_package#yyy00: view", - "test_domain#yyy00-aaaa.owner -> test_domain#yyy00-aaaa: *", - "test_domain#yyy00-aaab.owner -> test_domain#yyy00-aaab: *" + "test_customer#yyy:ADMIN -> test_customer#yyy: INSERT:test_package", + "test_customer#yyy:ADMIN -> test_customer#yyy: SELECT", + "test_customer#yyy:TENANT -> test_customer#yyy: SELECT", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:TENANT -> test_package#yyy00: SELECT", + "test_domain#yyy00-aaaa:OWNER -> test_domain#yyy00-aaaa: DELETE", + "test_domain#yyy00-aaab:OWNER -> test_domain#yyy00-aaab: DELETE" // @formatter:on ); } @@ -359,27 +360,26 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { allTheseRbacPermissionsAreReturned( result, // @formatter:off - "test_customer#xxx.tenant -> test_customer#xxx: view", - // "test_customer#xxx.admin -> test_customer#xxx: view" - Not permissions through the customer admin! - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.admin -> test_package#xxx00: add-domain", - "test_package#xxx00.tenant -> test_package#xxx00: view" + "test_customer#xxx:TENANT -> test_customer#xxx: SELECT", + // "test_customer#xxx:ADMIN -> test_customer#xxx: view" - Not permissions through the customer admin! + "test_package#xxx00:ADMIN -> test_package#xxx00: INSERT:test_domain", + "test_package#xxx00:TENANT -> test_package#xxx00: SELECT" // @formatter:on ); noneOfTheseRbacPermissionsAreReturned( result, // @formatter:off // no customer admin permissions - "test_customer#xxx.admin -> test_customer#xxx: add-package", + "test_customer#xxx:ADMIN -> test_customer#xxx: add-package", // no permissions on other customer's objects - "test_customer#yyy.admin -> test_customer#yyy: add-package", - "test_customer#yyy.admin -> test_customer#yyy: view", - "test_customer#yyy.tenant -> test_customer#yyy: view", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.admin -> test_package#yyy00: add-domain", - "test_package#yyy00.tenant -> test_package#yyy00: view", - "test_domain#yyy00-aaaa.owner -> test_domain#yyy00-aaaa: *", - "test_domain#yyy00-xxxb.owner -> test_domain#yyy00-xxxb: *" + "test_customer#yyy:ADMIN -> test_customer#yyy: add-package", + "test_customer#yyy:ADMIN -> test_customer#yyy: SELECT", + "test_customer#yyy:TENANT -> test_customer#yyy: SELECT", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:ADMIN -> test_package#yyy00: INSERT:test_domain", + "test_package#yyy00:TENANT -> test_package#yyy00: SELECT", + "test_domain#yyy00-aaaa:OWNER -> test_domain#yyy00-aaaa: DELETE", + "test_domain#yyy00-xxxb:OWNER -> test_domain#yyy00-xxxb: DELETE" // @formatter:on ); } @@ -432,7 +432,8 @@ class RbacUserRepositoryIntegrationTest extends ContextBasedTest { final List actualResult, final String... expectedRoleNames) { assertThat(actualResult) - .extracting(p -> p.getRoleName() + " -> " + p.getObjectTable() + "#" + p.getObjectIdName() + ": " + p.getOp()) + .extracting(p -> p.getRoleName() + " -> " + p.getObjectTable() + "#" + p.getObjectIdName() + ": " + p.getOp() + + (p.getOpTableName() != null ? (":"+p.getOpTableName()) : "" )) .contains(expectedRoleNames); } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java new file mode 100644 index 00000000..366e79d7 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java @@ -0,0 +1,382 @@ +package net.hostsharing.hsadminng.rbac.test; + +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantEntity; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService; +import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleEntity; +import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository; +import org.apache.commons.collections4.SetUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.transaction.PlatformTransactionManager; + +import jakarta.persistence.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import static java.lang.System.out; +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.collections4.SetUtils.difference; +import static org.assertj.core.api.Assertions.assertThat; + +// TODO.impl: cleanup the whole class +public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { + + private static final boolean DETAILED_BUT_SLOW_CHECK = false; + + @PersistenceContext + protected EntityManager em; + + @Autowired + private PlatformTransactionManager tm; + + @Autowired + RbacGrantRepository rbacGrantRepo; + + @Autowired + RbacRoleRepository rbacRoleRepo; + + @Autowired + RbacObjectRepository rbacObjectRepo; + + @Autowired + JpaAttempt jpaAttempt; + + private TreeMap> entitiesToCleanup = new TreeMap<>(); + + private static Long latestIntialTestDataSerialId; + private static boolean countersInitialized = false; + private static boolean initialTestDataValidated = false; + static private Long previousRbacObjectCount; + private Long initialRbacObjectCount = null; + private Long initialRbacRoleCount = null; + private Long initialRbacGrantCount = null; + private Set initialRbacObjects; + private Set initialRbacRoles; + private Set initialRbacGrants; + + private TestInfo testInfo; + + public T refresh(final T entity) { + final var merged = em.merge(entity); + em.refresh(merged); + return merged; + } + + public UUID toCleanup(final Class entityClass, final UUID uuidToCleanup) { + out.println("toCleanup(" + entityClass.getSimpleName() + ", " + uuidToCleanup + ")"); + entitiesToCleanup.put(uuidToCleanup, entityClass); + return uuidToCleanup; + } + + public E toCleanup(final E entity) { + out.println("toCleanup(" + entity.getClass() + ", " + entity.getUuid()); + if ( entity.getUuid() == null ) { + throw new IllegalArgumentException("only persisted entities with valid uuid allowed"); + } + entitiesToCleanup.put(entity.getUuid(), entity.getClass()); + return entity; + } + + protected void cleanupAllNew(final Class entityClass) { + if (initialRbacObjects == null) { + out.println("skipping cleanupAllNew: " + entityClass.getSimpleName()); + return; // TODO: seems @AfterEach is called without any @BeforeEach + } + + out.println("executing cleanupAllNew: " + entityClass.getSimpleName()); + + final var tableName = entityClass.getAnnotation(Table.class).name(); + final var rvTableName = tableName.endsWith("_rv") + ? tableName.substring(0, tableName.length() - "_rv".length()) + : tableName; + + allRbacObjects().stream() + .filter(o -> o.startsWith(rvTableName + ":")) + .filter(o -> !initialRbacObjects.contains(o)) + .forEach(o -> { + final UUID uuid = UUID.fromString(o.split(":")[1]); + + final var exception = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + em.remove(em.getReference(entityClass, uuid)); + out.println("DELETING new " + entityClass.getSimpleName() + "#" + uuid + " SUCCEEDED"); + }).caughtException(); + + if (exception != null) { + out.println("DELETING new " + entityClass.getSimpleName() + "#" + uuid + " FAILED: " + exception); + } + }); + } + + @BeforeEach + //@Transactional -- TODO: check why this does not work but jpaAttempt.transacted does work + void retrieveInitialTestData(final TestInfo testInfo) { + this.testInfo = testInfo; + out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".retrieveInitialTestData"); + + if (latestIntialTestDataSerialId == null ) { + latestIntialTestDataSerialId = rbacObjectRepo.findLatestSerialId(); + } + + if (initialRbacObjects != null){ + assertNoNewRbacObjectsRolesAndGrantsLeaked("before"); + } + + initialTestDataValidated = false; + + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + if (initialRbacObjects == null) { + + initialRbacObjects = allRbacObjects(); + initialRbacRoles = allRbacRoles(); + initialRbacGrants = allRbacGrants(); + + initialRbacObjectCount = rbacObjectRepo.count(); + initialRbacRoleCount = rbacRoleRepo.count(); + initialRbacGrantCount = rbacGrantRepo.count(); + + countersInitialized = true; + initialTestDataValidated = true; + } else { + initialRbacObjectCount = assumeSameInitialCount(initialRbacObjectCount, rbacObjectRepo.count(), "business objects"); + initialRbacRoleCount = assumeSameInitialCount(initialRbacRoleCount, rbacRoleRepo.count(), "rbac roles"); + initialRbacGrantCount = assumeSameInitialCount(initialRbacGrantCount, rbacGrantRepo.count(), "rbac grants"); + initialTestDataValidated = true; + } + }).reThrowException(); + + assertThat(countersInitialized).as("error while retrieving initial test data").isTrue(); + assertThat(initialTestDataValidated).as("check previous test for leaked test data").isTrue(); + + out.println(testInfo.getDisplayName() + ": TOTAL OBJECT COUNT (initial): " + previousRbacObjectCount + " -> " + initialRbacObjectCount); + if (previousRbacObjectCount != null) { + assertThat(initialRbacObjectCount).as("TOTAL OBJECT COUNT changed from " + previousRbacObjectCount + " to " + initialRbacObjectCount).isEqualTo(previousRbacObjectCount); + } + } + + private Long assumeSameInitialCount(final Long countBefore, final long currentCount, final String name) { + assertThat(currentCount) + .as("not all " + name + " got cleaned up by the previous tests") + .isEqualTo(countBefore); + return currentCount; + } + + @AfterEach + void cleanupAndCheckCleanup(final TestInfo testInfo) { + this.testInfo = testInfo; + + out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".cleanupAndCheckCleanup"); + cleanupTemporaryTestData(); + repeatUntilTrue(3, this::deleteLeakedRbacObjects); + + assertNoNewRbacObjectsRolesAndGrantsLeaked("after"); + } + + private void cleanupTemporaryTestData() { + // For better performance in a single transaction ... + final var exception = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + entitiesToCleanup.reversed().forEach((uuid, entityClass) -> { + final var rvTableName = entityClass.getAnnotation(Table.class).name(); + if ( !rvTableName.endsWith("_rv") ) { + throw new IllegalStateException(); + } + final var rawTableName = rvTableName.substring(0, rvTableName.length() - "_rv".length()); + final var deletedRows = em.createNativeQuery("DELETE FROM " + rawTableName + " WHERE uuid=:uuid") + .setParameter("uuid", uuid).executeUpdate(); + out.println("DELETING temporary " + entityClass.getSimpleName() + "#" + uuid + " deleted " + deletedRows + " rows"); + }); + }).caughtException(); + + // ... and in case of foreign key violations, we rely on the RbacObject cleanup. + if (exception != null) { + System.err.println(exception); + } + } + + private void assertNoNewRbacObjectsRolesAndGrantsLeaked(final String event) { + long rbacObjectCount = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + assertEqual(initialRbacObjects, allRbacObjects()); + if (DETAILED_BUT_SLOW_CHECK) { + assertEqual(initialRbacRoles, allRbacRoles()); + assertEqual(initialRbacGrants, allRbacGrants()); + } + + // The detailed check works with sets, thus it cannot determine duplicates. + // Therefore, we always compare the counts as well. + long count = rbacObjectRepo.count(); + out.println(testInfo.getDisplayName() + ": TOTAL OBJECT COUNT (" + event+ "): " + previousRbacObjectCount+ " -> " + count); + assertThat(count).as("not all business objects got cleaned up (by current test)") + .isEqualTo(initialRbacObjectCount); + assertThat(rbacRoleRepo.count()).as("not all rbac roles got cleaned up (by current test)") + .isEqualTo(initialRbacRoleCount); + assertThat(rbacGrantRepo.count()).as("not all rbac grants got cleaned up (by current test)") + .isEqualTo(initialRbacGrantCount); + return count; + }).assertSuccessful().returnedValue(); + + if (previousRbacObjectCount != null) { + assertThat(rbacObjectCount).as("TOTAL OBJECT COUNT changed from " + previousRbacObjectCount + " to " + rbacObjectCount).isEqualTo(previousRbacObjectCount); + } + previousRbacObjectCount = rbacObjectCount; + } + + private boolean deleteLeakedRbacObjects() { + final var deletionSuccessful = new AtomicBoolean(true); + jpaAttempt.transacted(() -> rbacObjectRepo.findAll()).assertSuccessful().returnedValue().stream() + .filter(o -> latestIntialTestDataSerialId != null && o.serialId > latestIntialTestDataSerialId) + .sorted(comparing(o -> o.serialId)) + .forEach(o -> { + final var exception = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + + em.createNativeQuery("DELETE FROM " + o.objectTable + " WHERE uuid=:uuid") + .setParameter("uuid", o.uuid) + .executeUpdate(); + + out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " SUCCEEDED"); + }).caughtException(); + + if (exception != null) { + out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " FAILED " + exception); + deletionSuccessful.set(false); + } + }); + return deletionSuccessful.get(); + } + + private void assertEqual(final Set before, final Set after) { + assertThat(before).isNotNull(); + assertThat(after).isNotNull(); + final SetUtils.SetView difference = difference(before, after); + assertThat(difference).as("missing entities (deleted initial test data)").isEmpty(); + assertThat(difference(after, before)).as("spurious entities (test data not cleaned up by this test)").isEmpty(); + } + + @NotNull + private Set allRbacGrants() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + return rbacGrantRepo.findAll().stream() + .map(RbacGrantEntity::toDisplay) + .collect(toSet()); + }).assertSuccessful().returnedValue(); + } + + @NotNull + private Set allRbacRoles() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + return rbacRoleRepo.findAll().stream() + .map(RbacRoleEntity::getRoleName) + .collect(toSet()); + }).assertSuccessful().returnedValue(); + } + + @NotNull + private Set allRbacObjects() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + return rbacObjectRepo.findAll().stream() + .map(RbacObjectEntity::toString) + .collect(toSet()); + }).assertSuccessful().returnedValue(); + } + + /** + * @return an array of all RBAC roles matching the given pattern + * + * Usually unused, but for temporary debugging purposes of findind role names for new tests. + */ + @SuppressWarnings("unused") + protected String[] roleNames(final String sqlLikeExpression) { + final var pattern = Pattern.compile(sqlLikeExpression); + //noinspection unchecked + final List rows = (List) em.createNativeQuery("select * from rbacrole_ev where roleidname like 'hs_booking_project#%'") + .getResultList(); + return rows.stream() + .map(row -> (row[0]).toString()) + .filter(roleName -> pattern.matcher(roleName).matches()) + .toArray(String[]::new); + } + + /** + * Generates a diagram of the RBAC-Grants to the current subjects (user or assumed roles). + * + * Usually unused, but for temporary use for debugging and other analysis. + */ + @SuppressWarnings("unused") + protected void generateRbacDiagramForCurrentSubjects(final EnumSet include, final String name) { + RbacGrantsDiagramService.writeToFile( + name, + diagramService.allGrantsToCurrentUser(include), + "doc/temp/" + name + ".md" + ); + } + + /** + * Generates a diagram of the RBAC-Grants for the given object and permission. + * + * Usually unused, but for temporary use for debugging and other analysis. + */ + @SuppressWarnings("unused") + protected void generateRbacDiagramForObjectPermission(final UUID targetObject, final String rbacOp, final String name) { + RbacGrantsDiagramService.writeToFile( + name, + diagramService.allGrantsFrom(targetObject, rbacOp, RbacGrantsDiagramService.Include.ALL), + "doc/temp/" + name + ".md" + ); + } + + public static boolean repeatUntilTrue(int maxAttempts, Supplier method) { + for (int attempts = 0; attempts < maxAttempts; attempts++) { + if (method.get()) { + return true; + } + } + return false; + } +} + +interface RbacObjectRepository extends Repository { + + long count(); + + List findAll(); + + @Query("SELECT max(r.serialId) FROM RbacObjectEntity r") + Long findLatestSerialId(); +} + +@Entity +@Table(name = "rbacobject") +class RbacObjectEntity { + + @Id + @GeneratedValue + UUID uuid; + + @Column(name = "serialid") + long serialId; + + @Column(name = "objecttable") + String objectTable; + + @Override + public String toString() { + return objectTable + ":" + uuid + ":" + serialId; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/EntityList.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/EntityList.java new file mode 100644 index 00000000..42469ea7 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/EntityList.java @@ -0,0 +1,15 @@ +package net.hostsharing.hsadminng.rbac.test; + +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EntityList { + + public static E one(final List entities) { + assertThat(entities).hasSize(1); + return entities.stream().findFirst().orElseThrow(); + } +} diff --git a/src/test/java/net/hostsharing/test/IsValidUuidMatcher.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/IsValidUuidMatcher.java similarity index 97% rename from src/test/java/net/hostsharing/test/IsValidUuidMatcher.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/IsValidUuidMatcher.java index 37d523ce..531c89c4 100644 --- a/src/test/java/net/hostsharing/test/IsValidUuidMatcher.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/IsValidUuidMatcher.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java similarity index 91% rename from src/test/java/net/hostsharing/test/JpaAttempt.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java index 589049bb..dcf31c5d 100644 --- a/src/test/java/net/hostsharing/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import org.assertj.core.api.ObjectAssert; import org.springframework.beans.factory.annotation.Autowired; @@ -77,11 +77,11 @@ public class JpaAttempt { public static class JpaResult { - private final T result; + private final T value; private final RuntimeException exception; - private JpaResult(final T result, final RuntimeException exception) { - this.result = result; + private JpaResult(final T value, final RuntimeException exception) { + this.value = value; this.exception = exception; } @@ -102,7 +102,7 @@ public class JpaAttempt { } public T returnedValue() { - return result; + return value; } public ObjectAssert assertThatResult() { @@ -136,6 +136,13 @@ public class JpaAttempt { } } + public JpaResult reThrowException() { + if (exception != null) { + throw exception; + } + return this; + } + public JpaResult assumeSuccessful() { assertThat(exception).as(firstRootCauseMessageLineOf(exception)).isNull(); return this; @@ -146,6 +153,11 @@ public class JpaAttempt { return this; } + public JpaResult assertNotNull() { + assertThat(returnedValue()).isNotNull(); + return this; + } + private String firstRootCauseMessageLineOf(final RuntimeException exception) { final var rootCause = NestedExceptionUtils.getRootCause(exception); return Optional.ofNullable(rootCause) diff --git a/src/test/java/net/hostsharing/test/JsonBuilder.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonBuilder.java similarity index 97% rename from src/test/java/net/hostsharing/test/JsonBuilder.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/JsonBuilder.java index 7934cefa..35a29d90 100644 --- a/src/test/java/net/hostsharing/test/JsonBuilder.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonBuilder.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import org.json.JSONException; import org.json.JSONObject; diff --git a/src/test/java/net/hostsharing/test/JsonMatcher.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java similarity index 78% rename from src/test/java/net/hostsharing/test/JsonMatcher.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java index 3f5457c2..22ddead9 100644 --- a/src/test/java/net/hostsharing/test/JsonMatcher.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -9,13 +9,15 @@ import org.json.JSONException; import org.skyscreamer.jsonassert.JSONAssert; import org.skyscreamer.jsonassert.JSONCompareMode; +import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; + public class JsonMatcher extends BaseMatcher { - private final String expected; + private final String expectedJson; private JSONCompareMode compareMode; - public JsonMatcher(final String expected, final JSONCompareMode compareMode) { - this.expected = expected; + public JsonMatcher(final String expectedJson, final JSONCompareMode compareMode) { + this.expectedJson = expectedJson; this.compareMode = compareMode; } @@ -47,8 +49,8 @@ public class JsonMatcher extends BaseMatcher { return false; } try { - final var actualJson = new ObjectMapper().writeValueAsString(actual); - JSONAssert.assertEquals(expected, actualJson, compareMode); + final var actualJson = new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual); + JSONAssert.assertEquals(expectedJson, actualJson, compareMode); return true; } catch (final JSONException | JsonProcessingException e) { throw new AssertionError(e); @@ -59,5 +61,4 @@ public class JsonMatcher extends BaseMatcher { public void describeTo(final Description description) { description.appendText("leniently matches JSON"); } - } diff --git a/src/test/java/net/hostsharing/test/LocaleUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/LocaleUnitTest.java similarity index 89% rename from src/test/java/net/hostsharing/test/LocaleUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/LocaleUnitTest.java index 6071c4ea..109a9eba 100644 --- a/src/test/java/net/hostsharing/test/LocaleUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/LocaleUnitTest.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/test/MapperUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/MapperUnitTest.java similarity index 95% rename from src/test/java/net/hostsharing/test/MapperUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/MapperUnitTest.java index b90bea08..0e01cd05 100644 --- a/src/test/java/net/hostsharing/test/MapperUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/MapperUnitTest.java @@ -1,7 +1,7 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import lombok.*; -import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.errors.DisplayAs; import net.hostsharing.hsadminng.mapper.Mapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -88,7 +88,7 @@ class MapperUnitTest { ); assertThat(exception).isInstanceOf(ValidationException.class) - .hasMessage("Unable to find SubTargetBean1 with uuid " + GIVEN_UUID); + .hasMessage("Unable to find SubTargetBean1 by uuid: " + GIVEN_UUID); } @Test @@ -101,7 +101,7 @@ class MapperUnitTest { ); assertThat(exception).isInstanceOf(ValidationException.class) - .hasMessage("Unable to find SomeDisplayName with uuid " + GIVEN_UUID); + .hasMessage("Unable to find SomeDisplayName by uuid: " + GIVEN_UUID); } @Test @@ -217,7 +217,7 @@ class MapperUnitTest { @Setter @NoArgsConstructor @AllArgsConstructor - @DisplayName("SomeDisplayName") + @DisplayAs("SomeDisplayName") public static class SubTargetBean2 { private UUID uuid; diff --git a/src/test/java/net/hostsharing/test/OptionalFromJsonUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/OptionalFromJsonUnitTest.java similarity index 97% rename from src/test/java/net/hostsharing/test/OptionalFromJsonUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/OptionalFromJsonUnitTest.java index 52c22d0b..b2670887 100644 --- a/src/test/java/net/hostsharing/test/OptionalFromJsonUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/OptionalFromJsonUnitTest.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import net.hostsharing.hsadminng.mapper.OptionalFromJson; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/test/PatchUnitTestBase.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/PatchUnitTestBase.java similarity index 97% rename from src/test/java/net/hostsharing/test/PatchUnitTestBase.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/PatchUnitTestBase.java index 51f78bb4..97fa53ec 100644 --- a/src/test/java/net/hostsharing/test/PatchUnitTestBase.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/PatchUnitTestBase.java @@ -1,6 +1,6 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; -import net.hostsharing.hsadminng.hs.office.migration.HasUuid; +import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity; import net.hostsharing.hsadminng.mapper.EntityPatcher; import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; @@ -34,7 +34,7 @@ public abstract class PatchUnitTestBase { @Test @SuppressWarnings("unchecked") - void willPatchAllProperties() { + protected void willPatchAllProperties() { // given final var givenEntity = newInitialEntity(); final var patchResource = newPatchResource(); @@ -55,7 +55,7 @@ public abstract class PatchUnitTestBase { @ParameterizedTest @MethodSource("propertyTestCases") - void willPatchOnlyGivenProperty(final Property testCase) { + protected void willPatchOnlyGivenProperty(final Property testCase) { // given final var givenEntity = newInitialEntity(); @@ -233,7 +233,7 @@ public abstract class PatchUnitTestBase { } } - protected static class JsonNullableProperty extends Property { + protected static class JsonNullableProperty extends Property { private final BiConsumer> resourceSetter; public final RV givenPatchValue; diff --git a/src/test/java/net/hostsharing/test/StringTemplater.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/StringTemplater.java similarity index 94% rename from src/test/java/net/hostsharing/test/StringTemplater.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/StringTemplater.java index 38866f49..0435bf2c 100644 --- a/src/test/java/net/hostsharing/test/StringTemplater.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/StringTemplater.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import lombok.experimental.UtilityClass; diff --git a/src/test/java/net/hostsharing/test/StringifyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/StringifyUnitTest.java similarity index 92% rename from src/test/java/net/hostsharing/test/StringifyUnitTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/StringifyUnitTest.java index d8830335..0267871f 100644 --- a/src/test/java/net/hostsharing/test/StringifyUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/StringifyUnitTest.java @@ -1,4 +1,4 @@ -package net.hostsharing.test; +package net.hostsharing.hsadminng.rbac.test; import lombok.*; import lombok.experimental.FieldNameConstants; @@ -21,7 +21,7 @@ class StringifyUnitTest { public static class TestBean implements Stringifyable { private static Stringify toString = stringify(TestBean.class, "bean") - .withProp(TestBean.Fields.label, TestBean::getLabel) + .withProp(TestBean.Fields.caption, TestBean::getCaption) .withProp(TestBean.Fields.contentA, TestBean::getContentA) .withProp(TestBean.Fields.contentB, TestBean::getContentB) .withProp(TestBean.Fields.value, TestBean::getValue) @@ -29,7 +29,7 @@ class StringifyUnitTest { private UUID uuid; - private String label; + private String caption; private SubBeanWithUnquotedValues contentA; @@ -45,7 +45,7 @@ class StringifyUnitTest { @Override public String toShortString() { - return label; + return caption; } } @@ -103,14 +103,14 @@ class StringifyUnitTest { @Test void stringifyWhenAllPropsHaveValues() { - final var given = new TestBean(UUID.randomUUID(), "some label", + final var given = new TestBean(UUID.randomUUID(), "some caption", new SubBeanWithUnquotedValues("some key", "some value"), new SubBeanWithQuotedValues("some key", 1234), 42, false); final var result = given.toString(); assertThat(result).isEqualTo( - "bean(label='some label', contentA='some key:some value', contentB='some key:1234', value=42, active=false)"); + "bean(caption='some caption', contentA='some key:some value', contentB='some key:1234', value=42, active=false)"); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomer.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomer.java similarity index 72% rename from src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomer.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomer.java index bb00975f..95462ec7 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomer.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomer.java @@ -1,5 +1,4 @@ -package net.hostsharing.hsadminng.test.cust; - +package net.hostsharing.hsadminng.rbac.test.cust; public class TestCustomer { @@ -7,6 +6,6 @@ public class TestCustomer { static final TestCustomerEntity yyy = hsCustomer("yyy", 10002, "yyy@example.com"); static public TestCustomerEntity hsCustomer(final String prefix, final int reference, final String adminName) { - return new TestCustomerEntity(null, prefix, reference, adminName); + return new TestCustomerEntity(null, 0, prefix, reference, adminName); } } diff --git a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerControllerAcceptanceTest.java similarity index 92% rename from src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerControllerAcceptanceTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerControllerAcceptanceTest.java index 6c695caa..2d6d5a70 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerControllerAcceptanceTest.java @@ -1,10 +1,10 @@ -package net.hostsharing.hsadminng.test.cust; +package net.hostsharing.hsadminng.rbac.test.cust; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -89,7 +89,7 @@ class TestCustomerControllerAcceptanceTest { RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#yyy.admin") + .header("assumed-roles", "test_customer#yyy:ADMIN") .port(port) .when() .get("http://localhost/api/test/customers") @@ -148,7 +148,7 @@ class TestCustomerControllerAcceptanceTest { // finally, the new customer can be viewed by its own admin final var newUserUuid = UUID.fromString( location.substring(location.lastIndexOf('/') + 1)); - context.define("customer-admin@uuu.example.com"); + context.define("superuser-fran@hostsharing.net", "test_customer#uuu:ADMIN"); assertThat(testCustomerRepository.findByUuid(newUserUuid)) .hasValueSatisfying(c -> assertThat(c.getPrefix()).isEqualTo("uuu")); } @@ -159,7 +159,7 @@ class TestCustomerControllerAcceptanceTest { RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .contentType(ContentType.JSON) .body(""" { @@ -175,7 +175,8 @@ class TestCustomerControllerAcceptanceTest { .statusCode(403) .contentType(ContentType.JSON) .statusCode(403) - .body("message", containsString("add-customer not permitted for test_customer#xxx.admin")); + .body("message", containsString("ERROR: [403] insert into test_customer ")) + .body("message", containsString(" not allowed for current subjects {test_customer#xxx:ADMIN}")); // @formatter:on // finally, the new customer was not created @@ -204,7 +205,8 @@ class TestCustomerControllerAcceptanceTest { .statusCode(403) .contentType(ContentType.JSON) .statusCode(403) - .body("message", containsString("add-customer not permitted for customer-admin@yyy.example.com")); + .body("message", containsString("ERROR: [403] insert into test_customer ")) + .body("message", containsString(" not allowed for current subjects")); // @formatter:on // finally, the new customer was not created diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntityUnitTest.java new file mode 100644 index 00000000..e7107909 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerEntityUnitTest.java @@ -0,0 +1,54 @@ +package net.hostsharing.hsadminng.rbac.test.cust; + +import net.hostsharing.hsadminng.rbac.rbacdef.RbacViewMermaidFlowchartGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestCustomerEntityUnitTest { + + @Test + void definesRbac() { + final var rbacFlowchart = new RbacViewMermaidFlowchartGenerator(TestCustomerEntity.rbac()).toString(); + assertThat(rbacFlowchart).isEqualTo(""" + %%{init:{'flowchart':{'htmlLabels':false}}}%% + flowchart TB + + subgraph customer["`**customer**`"] + direction TB + style customer fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph customer:roles[ ] + style customer:roles fill:#dd4901,stroke:white + + role:customer:OWNER[[customer:OWNER]] + role:customer:ADMIN[[customer:ADMIN]] + role:customer:TENANT[[customer:TENANT]] + end + + subgraph customer:permissions[ ] + style customer:permissions fill:#dd4901,stroke:white + + perm:customer:INSERT{{customer:INSERT}} + perm:customer:DELETE{{customer:DELETE}} + perm:customer:UPDATE{{customer:UPDATE}} + perm:customer:SELECT{{customer:SELECT}} + end + end + + %% granting roles to users + user:creator ==>|XX| role:customer:OWNER + + %% granting roles to roles + role:global:ADMIN ==>|XX| role:customer:OWNER + role:customer:OWNER ==> role:customer:ADMIN + role:customer:ADMIN ==> role:customer:TENANT + + %% granting permissions to roles + role:global:ADMIN ==> perm:customer:INSERT + role:customer:OWNER ==> perm:customer:DELETE + role:customer:ADMIN ==> perm:customer:UPDATE + role:customer:TENANT ==> perm:customer:SELECT + """); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepositoryIntegrationTest.java similarity index 81% rename from src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepositoryIntegrationTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepositoryIntegrationTest.java index ca535142..831a2976 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/cust/TestCustomerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerRepositoryIntegrationTest.java @@ -1,8 +1,8 @@ -package net.hostsharing.hsadminng.test.cust; +package net.hostsharing.hsadminng.rbac.test.cust; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.context.ContextBasedTest; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -10,14 +10,12 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceException; import jakarta.servlet.http.HttpServletRequest; import java.util.List; import java.util.UUID; -import static net.hostsharing.test.JpaAttempt.attempt; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @@ -27,9 +25,6 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { @Autowired TestCustomerRepository testCustomerRepository; - @PersistenceContext - EntityManager em; - @MockBean HttpServletRequest request; @@ -43,10 +38,9 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { final var count = testCustomerRepository.count(); // when - final var result = attempt(em, () -> { final var newCustomer = new TestCustomerEntity( - UUID.randomUUID(), "www", 90001, "customer-admin@www.example.com"); + UUID.randomUUID(), 0, "www", 90001, "customer-admin@www.example.com"); return testCustomerRepository.save(newCustomer); }); @@ -60,19 +54,20 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { @Test public void globalAdmin_withAssumedCustomerRole_cannotCreateNewCustomer() { // given - context("superuser-alex@hostsharing.net", "test_customer#xxx.admin"); + context("superuser-alex@hostsharing.net", "test_customer#xxx:ADMIN"); // when final var result = attempt(em, () -> { final var newCustomer = new TestCustomerEntity( - UUID.randomUUID(), "www", 90001, "customer-admin@www.example.com"); + UUID.randomUUID(), 0, "www", 90001, "customer-admin@www.example.com"); return testCustomerRepository.save(newCustomer); }); // then result.assertExceptionWithRootCauseMessage( PersistenceException.class, - "add-customer not permitted for test_customer#xxx.admin"); + "ERROR: [403] insert into test_customer ", + "not allowed for current subjects {test_customer#xxx:ADMIN}"); } @Test @@ -83,20 +78,21 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { // when final var result = attempt(em, () -> { final var newCustomer = new TestCustomerEntity( - UUID.randomUUID(), "www", 90001, "customer-admin@www.example.com"); + UUID.randomUUID(), 0, "www", 90001, "customer-admin@www.example.com"); return testCustomerRepository.save(newCustomer); }); // then result.assertExceptionWithRootCauseMessage( PersistenceException.class, - "add-customer not permitted for customer-admin@xxx.example.com"); + "ERROR: [403] insert into test_customer ", + " not allowed for current subjects {customer-admin@xxx.example.com}"); } private void assertThatCustomerIsPersisted(final TestCustomerEntity saved) { final var found = testCustomerRepository.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -116,15 +112,15 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { } @Test - public void globalAdmin_withAssumedglobalAdminRole_canViewAllCustomers() { + public void globalAdmin_withAssumedCustomerOwnerRole_canViewExactlyThatCustomer() { given: - context("superuser-alex@hostsharing.net", "global#global.admin"); + context("superuser-alex@hostsharing.net", "test_customer#yyy:OWNER"); // when final var result = testCustomerRepository.findCustomerByOptionalPrefixLike(null); then: - allTheseCustomersAreReturned(result, "xxx", "yyy", "zzz"); + allTheseCustomersAreReturned(result, "yyy"); } @Test @@ -141,7 +137,9 @@ class TestCustomerRepositoryIntegrationTest extends ContextBasedTest { @Test public void customerAdmin_withAssumedOwnedPackageAdminRole_canViewOnlyItsOwnCustomer() { - context("customer-admin@xxx.example.com", "test_package#xxx00.admin"); + context("customer-admin@xxx.example.com"); + + context("customer-admin@xxx.example.com", "test_package#xxx00:ADMIN"); final var result = testCustomerRepository.findCustomerByOptionalPrefixLike(null); diff --git a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackage.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackage.java similarity index 75% rename from src/test/java/net/hostsharing/hsadminng/test/pac/TestPackage.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackage.java index b97daeaa..4c891478 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackage.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackage.java @@ -1,7 +1,7 @@ -package net.hostsharing.hsadminng.test.pac; +package net.hostsharing.hsadminng.rbac.test.pac; -import net.hostsharing.hsadminng.test.cust.TestCustomer; -import net.hostsharing.hsadminng.test.cust.TestCustomerEntity; +import net.hostsharing.hsadminng.rbac.test.cust.TestCustomer; +import net.hostsharing.hsadminng.rbac.test.cust.TestCustomerEntity; import static java.util.UUID.randomUUID; diff --git a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageControllerAcceptanceTest.java similarity index 93% rename from src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageControllerAcceptanceTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageControllerAcceptanceTest.java index fd51ebf8..a5e89330 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageControllerAcceptanceTest.java @@ -1,4 +1,4 @@ -package net.hostsharing.hsadminng.test.pac; +package net.hostsharing.hsadminng.rbac.test.pac; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -44,7 +44,7 @@ class TestPackageControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .port(port) .when() .get("http://localhost/api/test/packages") @@ -66,7 +66,7 @@ class TestPackageControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .port(port) .when() .get("http://localhost/api/test/packages?name=xxx01") @@ -95,7 +95,7 @@ class TestPackageControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .contentType(ContentType.JSON) .body(format(""" { @@ -126,7 +126,7 @@ class TestPackageControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .contentType(ContentType.JSON) .body(""" { @@ -156,7 +156,7 @@ class TestPackageControllerAcceptanceTest { RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .contentType(ContentType.JSON) .body("{}") .port(port) @@ -176,7 +176,7 @@ class TestPackageControllerAcceptanceTest { return UUID.fromString(RestAssured .given() .header("current-user", "superuser-alex@hostsharing.net") - .header("assumed-roles", "test_customer#xxx.admin") + .header("assumed-roles", "test_customer#xxx:ADMIN") .port(port) .when() .get("http://localhost/api/test/packages?name={packageName}", packageName) @@ -188,7 +188,7 @@ class TestPackageControllerAcceptanceTest { } String getDescriptionOfPackage(final String packageName) { - context.define("superuser-alex@hostsharing.net","test_customer#xxx.admin"); + context.define("superuser-alex@hostsharing.net","test_customer#xxx:ADMIN"); return testPackageRepository.findAllByOptionalNameLike(packageName).get(0).getDescription(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntityUnitTest.java new file mode 100644 index 00000000..824bb1bb --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageEntityUnitTest.java @@ -0,0 +1,68 @@ +package net.hostsharing.hsadminng.rbac.test.pac; + +import net.hostsharing.hsadminng.rbac.rbacdef.RbacViewMermaidFlowchartGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestPackageEntityUnitTest { + + @Test + void definesRbac() { + final var rbacFlowchart = new RbacViewMermaidFlowchartGenerator(TestPackageEntity.rbac()).toString(); + assertThat(rbacFlowchart).isEqualTo(""" + %%{init:{'flowchart':{'htmlLabels':false}}}%% + flowchart TB + + subgraph customer["`**customer**`"] + direction TB + style customer fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph customer:roles[ ] + style customer:roles fill:#99bcdb,stroke:white + + role:customer:OWNER[[customer:OWNER]] + role:customer:ADMIN[[customer:ADMIN]] + role:customer:TENANT[[customer:TENANT]] + end + end + + subgraph package["`**package**`"] + direction TB + style package fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph package:roles[ ] + style package:roles fill:#dd4901,stroke:white + + role:package:OWNER[[package:OWNER]] + role:package:ADMIN[[package:ADMIN]] + role:package:TENANT[[package:TENANT]] + end + + subgraph package:permissions[ ] + style package:permissions fill:#dd4901,stroke:white + + perm:package:INSERT{{package:INSERT}} + perm:package:DELETE{{package:DELETE}} + perm:package:UPDATE{{package:UPDATE}} + perm:package:SELECT{{package:SELECT}} + end + end + + %% granting roles to roles + role:global:ADMIN -.->|XX| role:customer:OWNER + role:customer:OWNER -.-> role:customer:ADMIN + role:customer:ADMIN -.-> role:customer:TENANT + role:customer:ADMIN ==> role:package:OWNER + role:package:OWNER ==> role:package:ADMIN + role:package:ADMIN ==> role:package:TENANT + role:package:TENANT ==> role:customer:TENANT + + %% granting permissions to roles + role:customer:ADMIN ==> perm:package:INSERT + role:package:OWNER ==> perm:package:DELETE + role:package:OWNER ==> perm:package:UPDATE + role:package:TENANT ==> perm:package:SELECT + """); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepositoryIntegrationTest.java similarity index 83% rename from src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageRepositoryIntegrationTest.java rename to src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepositoryIntegrationTest.java index 53d28e0c..a8fd8a50 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/pac/TestPackageRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageRepositoryIntegrationTest.java @@ -1,7 +1,8 @@ -package net.hostsharing.hsadminng.test.pac; +package net.hostsharing.hsadminng.rbac.test.pac; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,10 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @Import( { Context.class, JpaAttempt.class }) -class TestPackageRepositoryIntegrationTest { - - @Autowired - Context context; +class TestPackageRepositoryIntegrationTest extends ContextBasedTest { @Autowired TestPackageRepository testPackageRepository; @@ -40,9 +38,10 @@ class TestPackageRepositoryIntegrationTest { class FindAllByOptionalNameLike { @Test - public void globalAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() { + public void globalAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotAssumed() { // given - context.define("superuser-alex@hostsharing.net"); + // alex is not just global-admin but lso the creating user, thus we use fran + context.define("superuser-fran@hostsharing.net"); // when final var result = testPackageRepository.findAllByOptionalNameLike(null); @@ -52,9 +51,9 @@ class TestPackageRepositoryIntegrationTest { } @Test - public void globalAdmin_withAssumedglobalAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() { + public void globalAdmin_withAssumedglobalAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotAssumed() { given: - context.define("superuser-alex@hostsharing.net", "global#global.admin"); + context.define("superuser-alex@hostsharing.net", "global#global:ADMIN"); // when final var result = testPackageRepository.findAllByOptionalNameLike(null); @@ -77,7 +76,7 @@ class TestPackageRepositoryIntegrationTest { @Test public void customerAdmin_withAssumedOwnedPackageAdminRole_canViewOnlyItsOwnPackages() { - context.define("customer-admin@xxx.example.com", "test_package#xxx00.admin"); + context.define("customer-admin@xxx.example.com", "test_package#xxx00:ADMIN"); final var result = testPackageRepository.findAllByOptionalNameLike(null); @@ -89,19 +88,19 @@ class TestPackageRepositoryIntegrationTest { class OptimisticLocking { @Test - public void supportsOptimisticLocking() throws InterruptedException { + public void supportsOptimisticLocking() { // given - globalAdminWithAssumedRole("test_package#xxx00.admin"); + globalAdminWithAssumedRole("test_package#xxx00:ADMIN"); final var pac = testPackageRepository.findAllByOptionalNameLike("%").get(0); // when final var result1 = jpaAttempt.transacted(() -> { - globalAdminWithAssumedRole("test_package#xxx00.owner"); + globalAdminWithAssumedRole("test_package#xxx00:OWNER"); pac.setDescription("description set by thread 1"); testPackageRepository.save(pac); }); final var result2 = jpaAttempt.transacted(() -> { - globalAdminWithAssumedRole("test_package#xxx00.owner"); + globalAdminWithAssumedRole("test_package#xxx00:OWNER"); pac.setDescription("description set by thread 2"); testPackageRepository.save(pac); sleep(1500); diff --git a/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java b/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java new file mode 100644 index 00000000..5025555c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java @@ -0,0 +1,81 @@ +package net.hostsharing.hsadminng.system; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.junit.jupiter.api.condition.OS.LINUX; + +class SystemProcessUnitTest { + + @Test + @EnabledOnOs(LINUX) + void shouldExecuteAndFetchOutput() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("bash", "-c", "echo 'Hello, World!'; echo 'Error!' >&2"); + + // when + final var returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(0); + assertThat(process.getStdOut()).isEqualTo("Hello, World!\n"); + assertThat(process.getStdErr()).isEqualTo("Error!\n"); + } + + @Test + @EnabledOnOs(LINUX) + void shouldReturnErrorCode() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("false"); + + // when + final int returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(1); + } + + @Test + @EnabledOnOs(LINUX) + void shouldExecuteAndFeedInput() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("tr", "[:lower:]", "[:upper:]"); + + // when + final int returnCode = process.execute("Hallo"); + + // then + assertThat(returnCode).isEqualTo(0); + assertThat(process.getStdOut()).isEqualTo("HALLO\n"); + } + + @Test + void shouldThrowExceptionIfProgramNotFound() { + // given + final var process = new SystemProcess("non-existing program"); + + // when + final var exception = catchThrowable(process::execute); + + // then + assertThat(exception).isInstanceOf(IOException.class) + .hasMessage("Cannot run program \"non-existing program\": error=2, No such file or directory"); + } + + @Test + void shouldBeAbleToRunMultipleTimes() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("true"); + + // when + process.execute(); + final int returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(0); + } +} diff --git a/src/test/java/net/hostsharing/test/Accepts.java b/src/test/java/net/hostsharing/test/Accepts.java deleted file mode 100644 index 505b5d2e..00000000 --- a/src/test/java/net/hostsharing/test/Accepts.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.hostsharing.test; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.TYPE, ElementType.METHOD }) -public @interface Accepts { - - String[] value(); -} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index a4f570f9..7c3d2cff 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -4,8 +4,9 @@ spring: platform: postgres datasource: - url: jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers + url-tc: jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers url-local: jdbc:postgresql://localhost:5432/postgres + url: ${spring.datasource.url-tc} username: postgres password: password @@ -25,9 +26,9 @@ spring: liquibase: change-log: classpath:/db/changelog/db.changelog-master.yaml - contexts: tc,test,dev + contexts: tc,test,dev,pg_stat_statements logging: level: - liquibase: INFO - net.ttddyy.dsproxy.listener: DEBUG + liquibase: WARN + net.ttddyy.dsproxy.listener: DEBUG # HOWTO: log meaningful SQL statements diff --git a/src/test/resources/migration/asset-transactions.csv b/src/test/resources/migration/asset-transactions.csv deleted file mode 100644 index 12c2c39c..00000000 --- a/src/test/resources/migration/asset-transactions.csv +++ /dev/null @@ -1,9 +0,0 @@ -member_asset_id; bp_id; date; action; amount; comment -30000; 17; 2000-12-06; PAYMENT; 1280.00; for subscription A -31000; 20; 2000-12-06; PAYMENT; 128.00; for subscription B -32000; 17; 2005-01-10; PAYMENT; 2560.00; for subscription C -33001; 17; 2005-01-10; HANDOVER; -512.00; for transfer to 10 -33002; 20; 2005-01-10; ADOPTION; 512.00; for transfer from 7 -34001; 20; 2016-12-31; CLEARING; -8.00; for cancellation D -34002; 20; 2016-12-31; PAYBACK; -100.00; for cancellation D -34003; 20; 2016-12-31; LOSS; -20.00; for cancellation D diff --git a/src/test/resources/migration/business-partners.csv b/src/test/resources/migration/business-partners.csv deleted file mode 100644 index a31c2e9d..00000000 --- a/src/test/resources/migration/business-partners.csv +++ /dev/null @@ -1,4 +0,0 @@ -bp_id;member_id;member_code;member_since;member_until;member_role;author_contract;nondisc_contract;free;exempt_vat;indicator_vat;uid_vat -17;10017;hsh00-mih;2000-12-06;;Aufsichtsrat;2006-10-15;2001-10-15;false;false;NET;DE-VAT-007 -20;10020;hsh00-xyz;2000-12-06;2015-12-31;;;;false;false;GROSS; -22;11022;hsh00-xxx;2021-04-01;;;;;true;true;GROSS; diff --git a/src/test/resources/migration/contacts.csv b/src/test/resources/migration/contacts.csv deleted file mode 100644 index 3f185a50..00000000 --- a/src/test/resources/migration/contacts.csv +++ /dev/null @@ -1,13 +0,0 @@ -contact_id; bp_id; salut; first_name; last_name; title; firma; co; street; zipcode;city; country; phone_private; phone_office; phone_mobile; fax; email; roles - -# eine natürliche Person, implizites contractual -1101; 17; Herr; Michael; Mellies; ; ; ; Kleine Freiheit 50; 26524; Hage; DE; ; +49 4931 123456; +49 1522 123456;; mih@example.org; partner,billing,operation - -# eine juristische Person mit drei separaten Ansprechpartnern, vip-contact und ex-partner -1200; 20;; ; ; ; JM e.K.;; Wiesenweg 15; 12335; Berlin; DE; +49 30 6666666; +49 30 5555555; ; +49 30 6666666; jm-ex-partner@example.org; ex-partner -1201; 20; Frau; Jenny; Meyer-Billing; Dr.; JM GmbH;; Waldweg 5; 11001; Berlin; DE; +49 30 7777777; +49 30 1111111; ; +49 30 2222222; jm-billing@example.org; billing -1202; 20; Herr; Andrew; Meyer-Operation; ; JM GmbH;; Waldweg 5; 11001; Berlin; DE; +49 30 6666666; +49 30 3333333; ; +49 30 4444444; am-operation@example.org; operation,vip-contact,subscriber:operations-announce -1203; 20; Herr; Philip; Meyer-Contract; ; JM GmbH;; Waldweg 5; 11001; Berlin; DE; +49 30 6666666; +49 30 5555555; ; +49 30 6666666; pm-partner@example.org; partner,contractual,subscriber:members-announce,subscriber:customers-announce - -# eine juristische Person mit nur einem Ansprechpartner und explizitem contractual -1301; 22; ; Petra; Schmidt; ; Test PS;; ; ; ; ; ; ; ; ; ps@example.com; partner,billing,contractual,operation diff --git a/src/test/resources/migration/dump.sh b/src/test/resources/migration/dump.sh index e5183164..aa9cc0e3 100644 --- a/src/test/resources/migration/dump.sh +++ b/src/test/resources/migration/dump.sh @@ -6,6 +6,10 @@ dbname="hsh02_hsdb" username="hsh02_hsdb_readonly" target="/tmp" +if [ ! -z $DEST ]; +then + target=$DEST +fi dump() { sql="copy ($1) to stdout with csv header delimiter ';' quote '\"'" @@ -16,28 +20,84 @@ dump() { dump "select bp_id, member_id, member_code, member_since, member_until, member_role, author_contract, nondisc_contract, free, exempt_vat, indicator_vat, uid_vat from business_partner order by bp_id" \ - "business-partners.csv" + "office/business_partners.csv" dump "select contact_id, bp_id, salut, first_name, last_name, title, firma, co, street, zipcode, city, country, phone_private, phone_office, phone_mobile, fax, email, array_to_string(array_agg(role), ',') as roles from contact left join contactrole_ref using(contact_id) group by contact_id order by contact_id" \ - "contacts.csv" + "office/contacts.csv" dump "select sepa_mandat_id, bp_id, bank_customer, bank_name, bank_iban, bank_bic, mandat_ref, mandat_signed, mandat_since, mandat_until, mandat_used from sepa_mandat order by sepa_mandat_id" \ - "sepa-mandates.csv" + "office/sepa_mandates.csv" dump "select member_asset_id, bp_id, date, action, amount, comment from member_asset WHERE bp_id NOT IN (511912) order by member_asset_id" \ - "asset-transactions.csv" + "office/asset_transactions.csv" dump "select member_share_id, bp_id, date, action, quantity, comment from member_share WHERE bp_id NOT IN (511912) order by member_share_id" \ - "share-transactions.csv" + "office/share_transactions.csv" + +dump "select inet_addr_id, inet_addr, description + from inet_addr + order by inet_addr_id" \ + "hosting/inet_addr.csv" + +dump "select hive_id, hive_name, inet_addr_id, description + from hive + order by hive_id" \ + "hosting/hive.csv" + +dump "select packet_id, basepacket_code, packet_name, bp_id, hive_id, created, cancelled, cur_inet_addr_id, old_inet_addr_id, free + from packet + left join basepacket using (basepacket_id) + order by packet_id" \ + "hosting/packet.csv" + +dump "select packet_component_id, packet_id, quantity, basecomponent_code, created, cancelled + from packet_component + left join basecomponent using (basecomponent_id) + order by packet_component_id" \ + "hosting/packet_component.csv" + +dump "select unixuser_id, name, comment, shell, homedir, locked, packet_id, userid, quota_softlimit, quota_hardlimit, storage_softlimit, storage_hardlimit + from unixuser + order by unixuser_id" \ + "hosting/unixuser.csv" + +# weil das fehlt, muss group by komplett gesetzt werden: alter table domain add constraint PK_domain primary key (domain_id); +dump "select domain_id, domain_name, domain_since, domain_dns_master, domain_owner, valid_subdomain_names, passenger_python, passenger_nodejs, passenger_ruby, fcgi_php_bin, array_to_string(array_agg(domain_option_name), ',') as domainoptions + from domain + left join domain__domain_option using(domain_id) + left join domain_option using (domain_option_id) + group by domain.domain_id, domain.domain_name, domain_since, domain_dns_master, domain_owner, valid_subdomain_names, passenger_python, passenger_nodejs, passenger_ruby, fcgi_php_bin + order by domain.domain_id" \ + "hosting/domain.csv" + +dump "select emailaddr_id, domain_id, localpart, subdomain, target + from emailaddr + order by emailaddr_id" \ + "emailaddr.csv" + +dump "select emailalias_id, pac_id, name, target + from emailalias + order by emailalias_id" \ + "hosting/emailalias.csv" + +dump "select dbuser_id, engine, packet_id, name + from database_user + order by dbuser_id" \ + "hosting/database_user.csv" + +dump "select database_id, engine, packet_id, name, owner, encoding + from database + order by database_id" \ + "hosting/database.csv" diff --git a/src/test/resources/migration/hosting/database.csv b/src/test/resources/migration/hosting/database.csv new file mode 100644 index 00000000..3dc130b7 --- /dev/null +++ b/src/test/resources/migration/hosting/database.csv @@ -0,0 +1,22 @@ +database_id;engine;packet_id;name;owner;encoding + +1077;pgsql;630;hsh00_vorstand;hsh00_vorstand;LATIN1 +1786;mysql;630;hsh00_addr;hsh00;latin1 +1805;mysql;630;hsh00_dba;hsh00;LATIN-1 + +1858;pgsql;630;hsh00;hsh00;LATIN1 +1860;pgsql;630;hsh00_hsadmin;hsh00_hsadmin;UTF8 + +4931;pgsql;630;hsh00_phpPgSqlAdmin;hsh00_phpPgSqlAdmin;UTF8 +4932;pgsql;630;hsh00_phpPgSqlAdmin_new;hsh00_phpPgSqlAdmin;utf8 +4908;mysql;630;hsh00_mantis;hsh00_mantis;UTF-8 +4941;mysql;630;hsh00_phpMyAdmin;hsh00_phpMyAdmin;utf8 +4942;mysql;630;hsh00_phpMyAdmin_old;hsh00_phpMyAdmin;utf8 + +7520;mysql;1094;lug00_wla;lug00_wla;utf8 +7521;mysql;1094;lug00_wla_test;lug00_wla;utf8 +7522;pgsql;1094;lug00_ola;lug00_ola;UTF8 +7523;pgsql;1094;lug00_ola_Test;lug00_ola;UTF8 + +7604;mysql;1112;mim00_test;mim00_test;latin1 +7605;pgsql;1112;mim00_office;mim00_office;UTF8 diff --git a/src/test/resources/migration/hosting/database_user.csv b/src/test/resources/migration/hosting/database_user.csv new file mode 100644 index 00000000..8d43c218 --- /dev/null +++ b/src/test/resources/migration/hosting/database_user.csv @@ -0,0 +1,17 @@ +dbuser_id;engine;packet_id;name;password_hash + +1857;pgsql;10630;hsh00;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$JDiZmaxU+O+ByArLY/CkYZ8HbOk0r/I8LyABnno5gQs=:NI3T500/63dzI1B07Jh3UtQGlukS6JxuS0XoxM/QgAc= +1858;mysql;10630;hsh00;*59067A36BA197AD0A47D74909296C5B002A0FB9F +1859;pgsql;10630;hsh00_vorstand;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$54Wh+OGx/GaIvAia+I3k78jHGhqmYwe4+iLssmH5zhk=:D4Gq1z2Li2BVSaZrz1azDrs6pwsIzhq4+suK1Hh6ZIg= +1860;pgsql;10630;hsh00_hsadmin;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$54Wh+OGx/GaIvAia+I3k78jHGhqmYwe4+iLssmH5zhk=:D4Gq1z2Li2BVSaZrz1azDrs6pwsIzhq4+suK1Hh6ZIg= +1861;pgsql;10630;hsh00_hsadmin_ro;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$UhJnJJhmKANbcaG+izWK3rz5bmhhluSuiCJFlUmDVI8=:6AC4mbLfJGiGlEOWhpz9BivvMODhLLHOnRnnktJPgn8= +4931;pgsql;10630;hsh00_phpPgSqlAdmin;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$UhJnJJhmKANbcaG+izWK3rz5bmhhluSuiCJFlUmDVI8=:6AC4mbLfJGiGlEOWhpz9BivvMODhLLHOnRnnktJPgn8= +4908;mysql;10630;hsh00_mantis;*EA4C0889A22AAE66BBEBC88161E8CF862D73B44F +4909;mysql;10630;hsh00_mantis_ro;*B3BB6D0DA2EC01958616E9B3BCD2926FE8C38383 +4932;mysql;10630;hsh00_phpMyAdmin;*3188720B1889EF5447C722629765F296F40257C2 + +7520;mysql;11094;lug00_wla;*11667C0EAC42BF8B0295ABEDC7D2868A835E4DB5 +7522;pgsql;11094;lug00_ola;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$tir+cV3ZzOZeEWurwAJk+8qkvsTAWaBfwx846oYMOr4=:p4yk/4hHkfSMAFxSuTuh3RIrbSpHNBh7h6raVa3nt1c= + +7604;mysql;11112;mim00_test;*156CFD94A0594A5C3F4C6742376DDF4B8C5F6D90 +7605;pgsql;11112;mim00_office;SCRAM-SHA-256$4096:Zml4ZWQgc2FsdA==$43jziwd1o+nkfjE0zFbks24Zy5GK+km87B7vzEQt4So=:xRQntZxBxdo1JJbhkegnUFKHT0T8MDW75hkQs2S3z6k= diff --git a/src/test/resources/migration/hosting/domain.csv b/src/test/resources/migration/hosting/domain.csv new file mode 100644 index 00000000..0181c8df --- /dev/null +++ b/src/test/resources/migration/hosting/domain.csv @@ -0,0 +1,10 @@ +domain_id;domain_name;domain_since;domain_dns_master;domain_owner;valid_subdomain_names;passenger_python;passenger_nodejs;passenger_ruby;fcgi_php_bin;domainoptions +4531;l-u-g.org;2013-09-10;dns.hostsharing.net;5803;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +4532;linuxfanboysngirls.de;2013-09-10;dns.hostsharing.net;5809;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +4534;lug-mars.de;2013-09-10;dns.hostsharing.net;5809;www;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,letsencrypt,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +4581;1981.ist-im-netz.de;2013-09-17;dns.hostsharing.net;5964;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +4587;mellis.de;2013-09-17;dns.hostsharing.net;5964;www,michael,test,photos,static,input;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;htdocsfallback,indexes,includes,letsencrypt,multiviews,cgi,fastcgi,passenger +4589;ist-im-netz.de;2013-09-17;dns.hostsharing.net;5964;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;htdocsfallback,indexes,includes,letsencrypt,multiviews,cgi,fastcgi,passenger +4600;waera.de;2013-09-17;dns.hostsharing.net;5964;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +4604;xn--wra-qla.de;2013-09-17;dns.hostsharing.net;5964;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;greylisting,multiviews,indexes,htdocsfallback,includes,cgi,fastcgi,passenger +7662;dph-netzwerk.de;2021-06-02;h93.hostsharing.net;9596;*;/usr/bin/python3;/usr/bin/node;/usr/bin/ruby;/usr/lib/cgi-bin/php;htdocsfallback,indexes,autoconfig,greylisting,includes,letsencrypt,multiviews,cgi,fastcgi,passenger diff --git a/src/test/resources/migration/hosting/emailaddr.csv b/src/test/resources/migration/hosting/emailaddr.csv new file mode 100644 index 00000000..924641fe --- /dev/null +++ b/src/test/resources/migration/hosting/emailaddr.csv @@ -0,0 +1,72 @@ +emailaddr_id;domain_id;localpart;subdomain;target +54746;4531;abuse;;lug00 +54747;4531;postmaster;;nomail +54748;4531;webmaster;;bounce +54745;4531;lugmaster;;nobody +54749;4532;abuse;;lug00-mars +54750;4532;postmaster;;m.hinsel@example.org +54751;4532;webmaster;;m.hinsel@example.org +54755;4534;abuse;;lug00-marl +54756;4534;postmaster;;m.hinsel@example.org +54757;4534;webmaster;;m.hinsel@example.org +54760;4531;info;hamburg-west;peter.lottmann@example.com +54761;4531;lugmaster;hamburg-west;raoul.lottmann@example.com +54762;4531;postmaster;hamburg-west;raoul.lottmann@example.com +54763;4531;webmaster;hamburg-west;raoul.lottmann@example.com peter.lottmann@example.com +54764;4531;;eliza;eliza@example.net +54765;4531;;;lug00 +54766;4532;;;nomail +54767;4532;hostmaster;;hostmaster@example.net +54795;4534;;;bounce +54796;4534;hostmaster;;hostmaster@example.net +54963;4581;abuse;;mim00 +54964;4581;postmaster;;mim00 +54965;4581;webmaster;;mim00 +54981;4587;abuse;; +54982;4587;postmaster;;/dev/null +54983;4587;webmaster;;mim00 +54987;4589;abuse;;"" +54988;4589;postmaster;;mim00 +54989;4589;webmaster;;mim00 +55020;4600;abuse;;mim00 +55021;4600;postmaster;;mim00 +55022;4600;webmaster;;mim00 +55032;4604;abuse;;mim00 +55033;4604;postmaster;;mim00 +55034;4604;webmaster;;mim00 +55037;4587;;eberhard;eberhard@mellis.de +55038;4587;;marleen;marleen@mellis.de +55039;4587;;michael;mh@dump.mellis.de +55040;4587;lists;michael;mim00-lists +55041;4587;ooo;michael;mim00-ooo +55043;4587;;test;test@mellis.de +55044;4587;;trap;mim00-spam +55046;4587;anke;;anke@segelschule-jade.de +55052;4587;eberhard;;mellis@example.org +55053;4587;gitti;;gitta.mellis@gmx.de +55054;4587;imap;;mim00-imap +55057;4587;listar;;mim00-listar +55059;4587;marleen;;marleen.mellis@t-online.de +55060;4587;mime;;mh@dump.mellis.de +55061;4587;michael;;mh@dump.mellis.de +55062;4587;monika;;nomail +55063;4587;nobody;;nobody +55064;4587;palm;;mim00-imap +55065;4587;procmail;;mim00-mail +55066;4587;reporter.web.de;;nomail +55067;4587;script;;mim00-script +55068;4587;spamtrap;;mim00-spamtrap +55069;4587;susanne;;susanne.mellis@example.net +55070;4587;test;;mim00-test +55071;4587;ursula;;01234wasauchimmer@example.net +55072;4587;webcmstag;;mim00-webcmstag +55073;4587;wichtig;;mim00-imap,01234567@smsmail.example.org +55074;4604;;;@waera.de +60601;4589;highlander;;mim00-highlander,michael@mellis.de +65150;4589;little-sunshine;;mim00-marleen +75964;4589;;mail;michael@mellis.de +75965;4589;;;michael@mellis.de +77726;4587;chat;michael;mim00-chat +93790;7662;abuse;;dph00-dph +93791;7662;postmaster;;dph00-dph +93792;7662;webmaster;;dph00-dph diff --git a/src/test/resources/migration/hosting/emailalias.csv b/src/test/resources/migration/hosting/emailalias.csv new file mode 100644 index 00000000..4d5b31c9 --- /dev/null +++ b/src/test/resources/migration/hosting/emailalias.csv @@ -0,0 +1,10 @@ +emailalias_id;pac_id;name;target +2403;11094;lug00;michael.mellis@example.com +2405;11094;lug00-wla-listar;|/home/pacs/lug00/users/in/mailinglist/listar +2429;11112;mim00;mim12-mi@mim12.hostsharing.net +2431;11112;mim00-abruf;michael.mellis@hostsharing.net +2449;11112;mim00-hhfx;"mim00-hhfx,""|/usr/bin/formail -I 'Reply-To: hamburger-fx@example.net' | /usr/lib/sendmail mim00-hhfx-l""" +2451;11112;mim00-hhfx-l;:include:/home/pacs/mim00/etc/hhfx.list +2454;11112;mim00-dev.null; /dev/null +2455;11112;mim00-1_with_space;" ""|/home/pacs/mim00/install/corpslistar/listar""" +2456;11112;mim00-1_with_single_quotes;'|/home/pacs/rir00/mailinglist/ecartis -r kybs06-intern' diff --git a/src/test/resources/migration/hosting/hive.csv b/src/test/resources/migration/hosting/hive.csv new file mode 100644 index 00000000..97e5b551 --- /dev/null +++ b/src/test/resources/migration/hosting/hive.csv @@ -0,0 +1,26 @@ +hive_id;hive_name;inet_addr_id;description +1001;h00;358; +1002;h01;359; +1004;h02;360; +1007;h03;361; +1013;h04;430; +1014;h50;433; +1020;h05;354; +1021;h06;355; +1022;h07;357; +1028;h60;363; +1031;h63;431; +1037;h67;381; +1038;h97;537; +1039;h96;536; +1045;h74;485; +1050;h82;514; +1128;h19;565; +1148;h50;522; +1163;h92;457; +1173;h25;1759; +1192;h93;1778; +1193;h95;1779; +1205;vm1107;1861; +1208;vm1110;1864; +1210;vm1112;1833; diff --git a/src/test/resources/migration/hosting/inet_addr.csv b/src/test/resources/migration/hosting/inet_addr.csv new file mode 100644 index 00000000..bee797c4 --- /dev/null +++ b/src/test/resources/migration/hosting/inet_addr.csv @@ -0,0 +1,11 @@ +inet_addr_id;inet_addr;description +363;83.223.95.34; +381;83.223.95.52; +401;83.223.95.72; +402;83.223.95.73; +433;83.223.95.104; +457;83.223.95.128; +473;83.223.95.144; +574;83.223.95.245; +1168;83.223.79.72; +1790;83.223.94.179; diff --git a/src/test/resources/migration/hosting/packet.csv b/src/test/resources/migration/hosting/packet.csv new file mode 100644 index 00000000..6e27b41b --- /dev/null +++ b/src/test/resources/migration/hosting/packet.csv @@ -0,0 +1,11 @@ +packet_id;basepacket_code;packet_name;bp_id;hive_id;created;cancelled;cur_inet_addr_id;old_inet_addr_id;free +10630;PAC/WEB;hsh00;213;1014;2001-06-01;;473;;1 +10968;SRV/MGD;vm1061;132;1028;2013-04-01;;363;;0 +10978;SRV/MGD;vm1050;213;1014;2013-04-01;;433;;1 +11061;SRV/MGD;vm1068;100;1037;2013-08-19;;381;;f +11094;PAC/WEB;lug00;100;1037;2013-09-10;;1168;;1 +11111;PAC/WEB;xyz68;213;1037;2013-08-19;;401;;1 +11112;PAC/WEB;mim00;100;1037;2013-09-17;;402;;1 +11447;SRV/MGD;vm1093;213;1163;2014-11-28;;457;;t +19959;PAC/WEB;dph00;542;1163;2021-06-02;;574;;0 +23611;SRV/CLD;vm2097;541;;2022-08-10;;1790;;0 diff --git a/src/test/resources/migration/hosting/packet_component.csv b/src/test/resources/migration/hosting/packet_component.csv new file mode 100644 index 00000000..5dee11ad --- /dev/null +++ b/src/test/resources/migration/hosting/packet_component.csv @@ -0,0 +1,144 @@ +packet_component_id;packet_id;quantity;basecomponent_code;created;cancelled +46105;11094;10;TRAFFIC;2017-03-27; +46109;11094;5;MULTI;2017-03-27; +46111;11094;0;DAEMON;2017-03-27; +46113;11094;1024;QUOTA;2017-03-27; +46117;11112;0;DAEMON;2017-03-27; +46121;11112;20;TRAFFIC;2017-03-27; +46122;11112;5;MULTI;2017-03-27; +46123;11112;3072;QUOTA;2017-03-27; +46124;11111;3072;QUOTA;2017-03-27; +143133;11094;1;SLABASIC;2017-09-01; +143483;11112;1;SLABASIC;2017-09-01; +757383;11112;0;SLAEXT24H;; +770533;11094;0;SLAEXT24H;; +784283;11112;0;OFFICE;; +797433;11094;0;OFFICE;; +1228033;11112;0;STORAGE;; +1241433;11094;0;STORAGE;; +1266451;10978;0;SLAPLAT4H;2021-10-05; +1266452;10978;250;TRAFFIC;2021-10-05; +1266453;10978;0;SLAPLAT8H;2021-10-05; +1266454;10978;0;SLAMAIL4H;2021-10-05; +1266455;10978;0;SLAMARIA8H;2021-10-05; +1266456;10978;0;SLAPGSQL4H;2021-10-05; +1266457;10978;0;SLAWEB4H;2021-10-05; +1266458;10978;0;SLAMARIA4H;2021-10-05; +1266459;10978;0;SLAPGSQL8H;2021-10-05; +1266460;10978;0;SLAOFFIC8H;2021-10-05; +1266461;10978;0;SLAWEB8H;2021-10-05; +1266462;10978;256000;STORAGE;2021-10-05; +1266463;10978;153600;QUOTA;2021-10-05; +1266464;10978;0;SLAOFFIC4H;2021-10-05; +1266465;10978;32768;RAM;2021-10-05; +1266466;10978;4;CPU;2021-10-05; +1266467;10978;1;SLABASIC;2021-10-05; +1266468;10978;0;SLAMAIL8H;2021-10-05; +1275583;10978;0;SLAPLAT2H;2022-04-20; +1280533;10978;0;SLAWEB2H;2022-04-20; +1285483;10978;0;SLAMARIA2H;2022-04-20; +1290433;10978;0;SLAPGSQL2H;2022-04-20; +1295383;10978;0;SLAMAIL2H;2022-04-20; +1300333;10978;0;SLAOFFIC2H;2022-04-20; +1305933;11447;0;SLAWEB2H;2022-05-02; +1305934;11447;0;SLAPLAT4H;2022-05-02; +1305935;11447;0;SLAWEB8H;2022-05-02; +1305936;11447;0;SLAOFFIC4H;2022-05-02; +1305937;11447;0;SLAMARIA4H;2022-05-02; +1305938;11447;0;SLAOFFIC8H;2022-05-02; +1305939;11447;1;SLABASIC;2022-05-02; +1305940;11447;0;SLAMAIL8H;2022-05-02; +1305941;11447;0;SLAPGSQL4H;2022-05-02; +1305942;11447;6;CPU;2022-05-02; +1305943;11447;250;TRAFFIC;2022-05-02; +1305944;11447;0;SLAOFFIC2H;2022-05-02; +1305945;11447;0;SLAMAIL4H;2022-05-02; +1305946;11447;0;SLAPGSQL2H;2022-05-02; +1305947;11447;0;SLAMARIA2H;2022-05-02; +1305948;11447;0;SLAMARIA8H;2022-05-02; +1305949;11447;0;SLAWEB4H;2022-05-02; +1305950;11447;16384;RAM;2022-05-02; +1305951;11447;0;SLAPGSQL8H;2022-05-02; +1305952;11447;512000;STORAGE;2022-05-02; +1305953;11447;0;SLAMAIL2H;2022-05-02; +1305954;11447;0;SLAPLAT2H;2022-05-02; +1305955;11447;0;SLAPLAT8H;2022-05-02; +1305956;11447;307200;QUOTA;2022-05-02; +1312013;23611;1;SLABASIC;2022-08-10; +1312014;23611;0;BANDWIDTH;2022-08-10; +1312015;23611;12288;RAM;2022-08-10; +1312016;23611;25600;QUOTA;2022-08-10; +1312017;23611;0;SLAINFR8H;2022-08-10; +1312018;23611;0;STORAGE;2022-08-10; +1312019;23611;0;SLAINFR2H;2022-08-10; +1312020;23611;8;CPU;2022-08-10; +1312021;23611;250;TRAFFIC;2022-08-10; +1312022;23611;0;SLAINFR4H;2022-08-10; +1313883;10978;0;BANDWIDTH;; +1316583;11447;0;BANDWIDTH;; +1338074;10968;0;SLAMARIA2H;2023-09-05; +1338075;10968;384000;QUOTA;2023-09-05; +1338076;10968;1;SLAMAIL8H;2023-09-05; +1338077;10968;0;BANDWIDTH;2023-09-05; +1338078;10968;0;SLAWEB2H;2023-09-05; +1338079;10968;0;SLAOFFIC4H;2023-09-05; +1338080;10968;256000;STORAGE;2023-09-05; +1338081;10968;0;SLAPLAT4H;2023-09-05; +1338082;10968;0;SLAPGSQL2H;2023-09-05; +1338083;10968;0;SLAPLAT2H;2023-09-05; +1338084;10968;250;TRAFFIC;2023-09-05; +1338085;10968;1;SLAMARIA8H;2023-09-05; +1338086;10968;0;SLAPGSQL4H;2023-09-05; +1338087;10968;0;SLAMAIL2H;2023-09-05; +1338088;10968;1;SLAPLAT8H;2023-09-05; +1338089;10968;0;SLAWEB4H;2023-09-05; +1338090;10968;6;CPU;2023-09-05; +1338091;10968;1;SLAPGSQL8H;2023-09-05; +1338092;10968;0;SLAMARIA4H;2023-09-05; +1338093;10968;0;SLAMAIL4H;2023-09-05; +1338094;10968;14336;RAM;2023-09-05; +1338095;10968;0;SLAOFFIC2H;2023-09-05; +1338096;10968;0;SLAOFFIC8H;2023-09-05; +1338097;10968;1;SLABASIC;2023-09-05; +1338098;10968;1;SLAWEB8H;2023-09-05; +1339228;19959;20;TRAFFIC;2023-10-27; +1339229;19959;1;SLABASIC;2023-10-27; +1339230;19959;0;DAEMON;2023-10-27; +1339231;19959;25600;QUOTA;2023-10-27; +1339232;19959;0;STORAGE;2023-10-27; +1339233;19959;0;SLAEXT24H;2023-10-27; +1339234;19959;0;OFFICE;2023-10-27; +1339235;19959;1;MULTI;2023-10-27; +1341088;11061;0;SLAOFFIC2H;2023-12-14; +1341089;11061;0;SLAOFFIC8H;2023-12-14; +1341090;11061;256000;STORAGE;2023-12-14; +1341091;11061;0;SLAMAIL4H;2023-12-14; +1341092;11061;0;SLAMAIL2H;2023-12-14; +1341093;11061;0;SLAPLAT2H;2023-12-14; +1341094;11061;4096;RAM;2023-12-14; +1341095;11061;0;SLAPLAT4H;2023-12-14; +1341096;11061;1;SLAPGSQL8H;2023-12-14; +1341097;11061;2;CPU;2023-12-14; +1341098;11061;0;QUOTA;2023-12-14; +1341099;11061;0;SLAMAIL8H;2023-12-14; +1341100;11061;1;SLABASIC;2023-12-14; +1341101;11061;1;SLAMARIA8H;2023-12-14; +1341102;11061;0;SLAPGSQL4H;2023-12-14; +1341103;11061;0;SLAPGSQL2H;2023-12-14; +1341104;11061;0;SLAMARIA4H;2023-12-14; +1341105;11061;0;SLAOFFIC4H;2023-12-14; +1341106;11061;1;SLAPLAT8H;2023-12-14; +1341107;11061;0;BANDWIDTH;2023-12-14; +1341108;11061;1;SLAWEB8H;2023-12-14; +1341109;11061;0;SLAWEB2H;2023-12-14; +1341110;11061;0;SLAMARIA2H;2023-12-14; +1341111;11061;250;TRAFFIC;2023-12-14; +1341112;11061;0;SLAWEB4H;2023-12-14; +1346628;10630;0;SLAEXT24H;2024-03-19; +1346629;10630;0;OFFICE;2024-03-19; +1346630;10630;16384;QUOTA;2024-03-19; +1346631;10630;0;DAEMON;2024-03-19; +1346632;10630;10240;STORAGE;2024-03-19; +1346633;10630;1;SLABASIC;2024-03-19; +1346634;10630;50;TRAFFIC;2024-03-19; +1346635;10630;25;MULTI;2024-03-19; diff --git a/src/test/resources/migration/hosting/unixuser.csv b/src/test/resources/migration/hosting/unixuser.csv new file mode 100644 index 00000000..739899d2 --- /dev/null +++ b/src/test/resources/migration/hosting/unixuser.csv @@ -0,0 +1,20 @@ +unixuser_id;name;comment;shell;homedir;locked;packet_id;userid;quota_softlimit;quota_hardlimit;storage_softlimit;storage_hardlimit +6824;hsh00;Hostsharing Paket;/bin/bash;/home/pacs/hsh00;0;10630;10000;0;0;0;0 + +5803;lug00;LUGs;/bin/bash;/home/pacs/lug00;0;11094;102090;0;0;0;0 +5805;lug00-wla.1;Paul Klemm;/bin/bash;/home/pacs/lug00/users/deaf;0;11094;102091;4;0;0;0 +5809;lug00-wla.2;Walter Müller;/bin/bash;/home/pacs/lug00/users/marl;0;11094;102093;4;8;0;0 +5811;lug00-ola.a;LUG OLA - POP a;/usr/bin/passwd;/home/pacs/lug00/users/marl.a;1;11094;102094;0;0;0;0 +5813;lug00-ola.b;LUG OLA - POP b;/usr/bin/passwd;/home/pacs/lug00/users/marl.b;1;11094;102095;0;0;0;0 +5835;lug00-test;Test;/usr/bin/passwd;/home/pacs/lug00/users/test;0;11094;102106;2000000;4000000;20;0 + +6705;hsh00-mim;Michael Mellis;/bin/false;/home/pacs/hsh00/users/mi;0;10630;10003;0;0;0;0 +5961;xyz68;Monitoring h68;/bin/bash;/home/pacs/xyz68;0;11111;102141;0;0;0;0 +5964;mim00;Michael Mellis;/bin/bash;/home/pacs/mim00;0;11112;102147;0;0;0;0 +5966;mim00-1981;Jahrgangstreffen 1981;/bin/bash;/home/pacs/mim00/users/1981;0;11112;102148;128;256;0;0 +5990;mim00-mail;Mailbox;/bin/bash;/home/pacs/mim00/users/mail;0;11112;102160;0;0;0;0 + +7846;hsh00-dph;hsh00-uph;/bin/false;/home/pacs/hsh00/users/uph;0;10630;110568;0;0;0;0 +9546;dph00;Reinhard Wiese;/bin/bash;/home/pacs/dph00;0;19959;110593;0;0;0;0 +9596;dph00-dph;Domain admin;/bin/bash;/home/pacs/dph00/users/uph;0;19959;110594;0;0;0;0 + diff --git a/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1068.json b/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1068.json new file mode 100644 index 00000000..c3929078 --- /dev/null +++ b/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1068.json @@ -0,0 +1,267 @@ +{ + "1981.ist-im-netz.de": { + "DOM_OWNER": "mim00", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + }, + "mellis.de": { + "DOM_OWNER": "mim00", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": true, + "auto-AUTOCONFIG-RR": true, + "auto-AUTODISCOVER-RR": true, + "auto-DKIM-RR": true, + "auto-MAILSERVICES-RR": true, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": true, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": true, + "user-RR": [ + [ + "dump.hoennig.de.", + 21600, + "IN", + "CNAME", + "mih12.hostsharing.net." + ], + [ + "fotos.hoennig.de.", + 21600, + "IN", + "CNAME", + "mih12.hostsharing.net." + ], + [ + "maven.hoennig.de.", + 21600, + "IN", + "NS", + "dns1.hostsharing.net." + ] + ] + }, + "ist-im-netz.de": { + "DOM_OWNER": "mim00", + "TTL": 14400, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [ + [ + "1981.ist-im-netz.de.", + 14400, + "IN", + "NS", + "dns1.hostsharing.net." + ], + [ + "1981.ist-im-netz.de.", + 14400, + "IN", + "NS", + "dns2.hostsharing.net." + ], + [ + "1981.ist-im-netz.de.", + 14400, + "IN", + "NS", + "dns3.hostsharing.net." + ] + ] + }, + "l-u-g.de": { + "DOM_OWNER": "lug00", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + }, + "l-u-g.org": { + "DOM_OWNER": "lug00", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + }, + "linuxfanboysngirls.de": { + "DOM_OWNER": "lug00-wla.2", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + }, + "lug-mars.de": { + "DOM_OWNER": "lug00-wla.2", + "TTL": 14400, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": false, + "auto-NS-RR": true, + "auto-SOA": false, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [ + [ + "lug-mars.de.", + 14400, + "IN", + "SOA", + "dns1.hostsharing.net. hostmaster.hostsharing.net. 1611590905 10800 3600 604800 3600" + ], + [ + "lug-mars.de.", + 14400, + "IN", + "MX", + "10 mailin1.hostsharing.net." + ], + [ + "lug-mars.de.", + 14400, + "IN", + "MX", + "20 mailin2.hostsharing.net." + ], + [ + "lug-mars.de.", + 14400, + "IN", + "MX", + "30 mailin3.hostsharing.net." + ], + [ + "bbb.lug-mars.de.", + 14400, + "IN", + "A", + "83.223.79.72" + ], + [ + "ftp.lug-mars.de.", + 14400, + "IN", + "A", + "83.223.79.72" + ], + [ + "www.lug-mars.de.", + 14400, + "IN", + "A", + "83.223.79.72" + ] + ] + }, + "waera.de": { + "DOM_OWNER": "mim00", + "TTL": 21600, + "auto-A-RR": false, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": false, + "auto-NS-RR": false, + "auto-SOA": false, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": false, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + }, + "xn--wra-qla.de": { + "DOM_OWNER": "mim00", + "TTL": 21600, + "auto-A-RR": false, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": false, + "auto-NS-RR": false, + "auto-SOA": false, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": false, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + } +} diff --git a/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1093.json b/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1093.json new file mode 100644 index 00000000..73416ba2 --- /dev/null +++ b/src/test/resources/migration/hosting/zonefiles/zonefiles-vm1093.json @@ -0,0 +1,89 @@ +{ + "dph-netzwerk.de": { + "DOM_OWNER": "dph00-dph", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": true, + "auto-AUTOCONFIG-RR": true, + "auto-AUTODISCOVER-RR": true, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": true, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": true, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [ + [ + "dph-netzwerk.de.", + 21600, + "IN", + "TXT", + "\"v=spf1 include:spf.hostsharing.net ?all\"" + ], + [ + "*.dph-netzwerk.de.", + 21600, + "IN", + "TXT", + "\"v=spf1 include:spf.hostsharing.net ?all\"" + ] + ] + }, + "mellis.de": { + "DOM_OWNER": "old00", + "TTL": 21600, + "auto-A-RR": true, + "auto-AAAA-RR": true, + "auto-AUTOCONFIG-RR": true, + "auto-AUTODISCOVER-RR": true, + "auto-DKIM-RR": true, + "auto-MAILSERVICES-RR": true, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": true, + "auto-WILDCARD-MX-RR": true, + "auto-WILDCARD-SPF-RR": true, + "user-RR": [ + [ + "dump.mellis.de.", + 21600, + "IN", + "CNAME", + "mih12.hostsharing.net." + ], + [ + "key1._domainkey.mellis.de.", + 21600, + "IN", + "TXT", + "\"v=DKIM1; k=rsa; t=s; h=sha256; s=email; \" \"p=OldFake+sN5uMa/\" \"OldFake/OldFake+OldFake/W2IITXPbLd9Z/OldFake+OldFake/\" \"OldFake+OldFake+OldFake\"" + ] + ] + }, + "ist-im-netz.de": { + "DOM_OWNER": "mim00", + "TTL": 700, + "auto-A-RR": true, + "auto-AAAA-RR": false, + "auto-AUTOCONFIG-RR": false, + "auto-AUTODISCOVER-RR": false, + "auto-DKIM-RR": false, + "auto-MAILSERVICES-RR": false, + "auto-MX-RR": true, + "auto-NS-RR": true, + "auto-SOA": true, + "auto-SPF-RR": false, + "auto-WILDCARD-A-RR": true, + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "user-RR": [] + } +} diff --git a/src/test/resources/migration/office/asset_transactions.csv b/src/test/resources/migration/office/asset_transactions.csv new file mode 100644 index 00000000..a0a83e02 --- /dev/null +++ b/src/test/resources/migration/office/asset_transactions.csv @@ -0,0 +1,19 @@ +member_asset_id; bp_id; date; action; amount; comment +358; 100; 2000-12-06; PAYMENT; 5120; for subscription A +442; 132; 2003-07-07; PAYMENT; 64; +577; 100; 2011-12-12; PAYMENT; 1024; +632; 132; 2013-10-21; PAYMENT; 64; +885; 100; 2020-12-15; PAYMENT; 6144; Einzahlung +924; 541; 2021-05-21; PAYMENT; 256; Beitritt - Lastschrift +925; 542; 2021-05-31; PAYMENT; 64; Beitritt - Lastschrift +1093; 100; 2023-10-05; PAYMENT; 3072; Kapitalerhoehung - Ueberweisung +1094; 100; 2023-10-06; PAYMENT; 3072; Kapitalerhoehung - Ueberweisung +31000; 120; 2000-12-06; PAYMENT; 128.00; for subscription B +32000; 100; 2005-01-10; PAYMENT; 2560.00; for subscription C +33001; 100; 2005-01-10; HANDOVER; -512.00; for transfer to 10 +33002; 120; 2005-01-10; ADOPTION; 512.00; for transfer from 7 +34001; 120; 2016-12-31; CLEARING; -8.00; for cancellation D +34002; 120; 2016-12-31; PAYBACK; -100.00; for cancellation D +34003; 120; 2016-12-31; LOSS; -20.00; for cancellation D +35001; 190; 2024-01-15; PAYMENT; 128.00; for subscription E +35002; 190; 2024-01-20; ADJUSTMENT;-128.00; chargeback for subscription E diff --git a/src/test/resources/migration/office/business_partners.csv b/src/test/resources/migration/office/business_partners.csv new file mode 100644 index 00000000..708f1d47 --- /dev/null +++ b/src/test/resources/migration/office/business_partners.csv @@ -0,0 +1,10 @@ +bp_id;member_id;member_code;member_since;member_until;member_role;author_contract;nondisc_contract;free;exempt_vat;indicator_vat;uid_vat +100;10003;hsh00-mim;2000-12-06;;Aufsichtsrat;;2001-04-24;0;0;GROSS;DE217249198 +132;10152;hsh00-rar;2003-07-12;;;;;0;0;GROSS;DE 236 109 136 +213;10000;hsh00-hsh;;;Hostsharing eG;;;1;0;GROSS; +541;11018;hsh00-wws;2021-05-17;;;;;0;0;GROSS; +542;11019;hsh00-dph;2021-05-25;;;;;0;0;GROSS; +120;10020;hsh00-xyz;2000-12-06;2015-12-31;;;;false;false;GROSS; +122;11022;hsh00-xxx;2021-04-01;;;;;true;true;GROSS; +190;19090;hsh00-yyy;;;;;;true;true;GROSS; +199;19999;hsh00-zzz;;;;;;false;false;GROSS; diff --git a/src/test/resources/migration/office/contacts.csv b/src/test/resources/migration/office/contacts.csv new file mode 100644 index 00000000..eb58efae --- /dev/null +++ b/src/test/resources/migration/office/contacts.csv @@ -0,0 +1,35 @@ +contact_id; bp_id; salut; first_name; last_name; title; firma; co; street; zipcode; city; country; phone_private; phone_office; phone_mobile; fax; email; roles + +# Hostsharing, the mandate itself +212; 213; Firma; Hostmaster; Hostsharing; ; Hostsharing e.G.; ; ; ; ; Germany; ; ; ; ; hostmaster@hostsharing.net; billing,operation,contractual,partner + +# some natural persons +100; 100; Herr; Michael; Mellis; ; Michael Mellis; ; Dr. Bolte Str. 50; 26524; Hage; Germany; ; +49 4931/1234567; +49/1522123455; +49 40 912345-9; michael@Mellis.example.org; billing,operation,contractual,partner,subscriber:members-announce,subscriber:operations-announce,subscriber:operations-discussion,subscriber:members-discussion,subscriber:generalversammlung +132; 132; Herr; Ragnar; Richter; ; Ragnar IT-Beratung; ; Vioktoriastraße 114; 70197; Stuttgart; Germany; +49 711 987654-1; +49 711 987654-2; ; +49 711 87654-3; hostsharing@ragnar-richter.de; billing,operation,partner,subscriber:operations-announce,subscriber:operations-discussion + +# eine juristische Person mit drei separaten Ansprechpartnern, vip-contact und ex-partner +1200; 120; ; ; ; ; JM e.K.; ; Wiesenweg 15; 12335; Berlin; DE; +49 30 6666666; +49 30 5555555; ; +49 30 6666666; jm-ex-partner@example.org; ex-partner +1201; 120; Frau; Jenny; Meyer-Billing; Dr.; JM GmbH; ; Waldweg 5; 11001; Berlin; DE; +49 30 7777777; +49 30 1111111; ; +49 30 2222222; jm-billing@example.org; billing +1202; 120; Herr; Andrew; Meyer-Operation; ; JM GmbH; ; Waldweg 5; 11001; Berlin; DE; +49 30 6666666; +49 30 3333333; ; +49 30 4444444; am-operation@example.org; operation,vip-contact,subscriber:operations-announce +1203; 120; Herr; Philip; Meyer-Contract; ; JM GmbH; ; Waldweg 5; 11001; Berlin; DE; +49 30 6666666; +49 30 5555555; ; +49 30 6666666; pm-partner@example.org; partner,contractual,subscriber:members-announce,subscriber:customers-announce +1204; 120; Frau; Tammy; Meyer-VIP; ; JM GmbH; ; Waldweg 5; 11001; Berlin; DE; +49 30 999999; +49 30 999999; ; +49 30 6666666; tm-vip@example.org; vip-contact + +# eine juristische Person mit nur einem Ansprechpartner und explizitem contractual +1301; 122; ; Petra; Schmidt; ; Test PS; ; ; ; ; ; ; ; ; ; ps@example.com; partner,billing,contractual,operation + +# eine natürliche Person, die nur Subscriber ist +1401; 120; Frau; Frauke; Fanninga; ; ; ; Am Walde 1; 29456; Hitzacker; DE; ; ; ; ; ff@example.org; subscriber:operations-announce + +# eine natürliche Person als Partner +1501; 190; Frau; Cecilia; Camus; ; ; ; Rue d'Avignion 60; 45000; Orléans; FR; ; ; ; ; cc@example.org; partner,contractual,billing,operation + +# some more contacts of realistic business partners + +90436; 541; Frau; Christiane; Milberg; ; Wasserwerk Südholstein; ; Am Wasserwerk 1-3; 25491; Hetlingen; Germany; ; ; +49 4103 12345-1; ; rechnung@ww-sholst.example.org; billing,partner,subscriber:members-discussion,contractual,subscriber:members-announce,subscriber:generalversammlung +90437; 542; Herr; Richard; Wiese; ; Das Perfekte Haus; ; Kennedyplatz 11; 45279; Essen; Germany; ; ; +49-172-12345; ; admin@das-perfekte-haus.example.org; operation,partner,subscriber:members-discussion,contractual,subscriber:operations-announce,subscriber:operations-discussion,subscriber:members-announce,subscriber:generalversammlung +90438; 541; Herr; Karim; Metzger; ; Wasswerwerk Südholstein; ; Am Wasserwerk 1-3; 25491; Hetlingen; Germany; ; +49 4103 12345-2; ; ; karim.metzger@ww-sholst.example.org; operation,subscriber:operations-announce,subscriber:operations-discussion +90590; 542; Herr; Inhaber R.; Wiese; ; Das Perfekte Haus; Client-ID 515217; Essen, Kastanienallee 81; 30127; Hannover; Germany; ; ; ; ; 515217@kkemail.example.org; billing +90629; 132; ; Ragnar; Richter; ; ; ; ; ; ; ; ; ; ; ; mail@ragnar-richter..example.org; contractual,subscriber:members-announce,subscriber:members-discussion,subscriber:generalversammlung +90677; 132; ; Eike; Henning; ; ; ; ; ; ; ; ; ; ; ; hostsharing@eike-henning..example.org; operation,subscriber:operations-announce,subscriber:operations-discussion +90698; 132; ; Jan; Henning; ; ; ; ; ; ; ; ; 01577 12345678; ; ; mail@jan-henning.example.org; operation + diff --git a/src/test/resources/migration/office/sepa_mandates.csv b/src/test/resources/migration/office/sepa_mandates.csv new file mode 100644 index 00000000..c2b8d936 --- /dev/null +++ b/src/test/resources/migration/office/sepa_mandates.csv @@ -0,0 +1,8 @@ +sepa_mandat_id;bp_id;bank_customer;bank_name;bank_iban;bank_bic;mandat_ref;mandat_signed;mandat_since;mandat_until;mandat_used +30;132;Ragnar Richter;GLS Gemeinschaftsbank eG;DE02300209000106531065;GENODEM1GLS;HS-10152-20140801;2013-12-01;2013-12-01;2016-02-15;2014-01-20 +132;100;Michael Mellis;Hamburger Volksbank;DE37500105177419788228;GENODEF1HH2;HS-10003-20140801;2013-12-01;2013-12-01;;2022-12-31 +386;541;Wasserwerk Suedholstein;Sparkasse Westholstein;DE49500105174516484892;NOLADE21WHO;HS-11018-20210512;2021-05-12;2021-05-17;;2022-12-31 +387;542;Richard Wiese Das Perfekte Haus;Commerzbank Wuppertal;DE89370400440532013000;COBADEFFXXX;HS-11019-20210519;2021-05-19;2021-05-25;;2022-12-31 +234234;100;Michael Mellis;ING Bank AG;DE37500105177419788228;INGDDEFFXXX;MH12345;2004-06-12;2004-06-15;;2022-10-20 +235600;120;JM e.K.;Targobank AG;DE02300209000106531065;CMCIDEDD;JM33344;2004-01-15;2004-01-20;2005-06-27 ;2016-01-18 +235662;120;JM GmbH;ING Bank AG;DE49500105174516484892;INGDDEFFXXX;JM33344;2005-06-28;2005-07-01;;2016-01-18 diff --git a/src/test/resources/migration/office/share_transactions.csv b/src/test/resources/migration/office/share_transactions.csv new file mode 100644 index 00000000..d42f9597 --- /dev/null +++ b/src/test/resources/migration/office/share_transactions.csv @@ -0,0 +1,12 @@ +member_share_id;bp_id;date;action;quantity;comment +3;100;2000-12-06;SUBSCRIPTION;80;initial share subscription +90;132;2003-07-12;SUBSCRIPTION;1; +241;100;2011-12-05;SUBSCRIPTION;16; +279;132;2013-10-21;SUBSCRIPTION;1; +523;100;2020-12-08;SUBSCRIPTION;96;Kapitalerhoehung +562;541;2021-05-17;SUBSCRIPTION;4;Beitritt +563;542;2021-05-25;SUBSCRIPTION;1;Beitritt +721;100;2023-10-10;SUBSCRIPTION;96;Kapitalerhoehung +33451;120;2000-12-06;SUBSCRIPTION;2;initial share subscription +33701;100;2005-01-10;SUBSCRIPTION;40;increase +33810;120;2016-12-31;UNSUBSCRIPTION;22;membership ended diff --git a/src/test/resources/migration/sepa-mandates.csv b/src/test/resources/migration/sepa-mandates.csv deleted file mode 100644 index a76adc16..00000000 --- a/src/test/resources/migration/sepa-mandates.csv +++ /dev/null @@ -1,4 +0,0 @@ -sepa_mandat_id; bp_id; bank_customer; bank_name; bank_iban; bank_bic; mandat_ref; mandat_signed; mandat_since; mandat_until; mandat_used -234234; 17; Michael Mellies; ING Bank AG; DE37500105177419788228; INGDDEFFXXX; MH12345; 2004-06-12; 2004-06-15; ; 2022-10-20 -235600; 20; JM e.K.; Targobank AG; DE02300209000106531065; CMCIDEDD; JM33344; 2004-01-15; 2004-01-20;2005-06-27 ;2016-01-18 -235662; 20; JM GmbH; ING Bank AG; DE49500105174516484892; INGDDEFFXXX; JM33344; 2005-06-28; 2005-07-01; ; 2016-01-18 diff --git a/src/test/resources/migration/share-transactions.csv b/src/test/resources/migration/share-transactions.csv deleted file mode 100644 index fa561419..00000000 --- a/src/test/resources/migration/share-transactions.csv +++ /dev/null @@ -1,5 +0,0 @@ -member_share_id;bp_id; date; action; quantity; comment -33443; 17; 2000-12-06; SUBSCRIPTION; 20; initial share subscription -33451; 20; 2000-12-06; SUBSCRIPTION; 2; initial share subscription -33701; 17; 2005-01-10; SUBSCRIPTION; 40; increase -33810; 20; 2016-12-31; UNSUBSCRIPTION; 22; membership ended diff --git a/tools/generate b/tools/generate deleted file mode 100755 index 93aa5c7c..00000000 --- a/tools/generate +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -sourceLower=partner -targetLower=relationship - -sourceStudly=Partner -targetStudly=Relationship - -## for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml -o -iname \*.sql -o -iname \*.java \)`; do -for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml \)`; do - target=`echo $source | sed -e "s/$sourceStudly/$targetStudly/g" -e "s/$sourceLower/$targetLower/g"` - echo "Generating $target from $source:" - - mkdir -p `dirname $target` - - sed -e 's/hs-office-partner/hs-office-relationship/g' \ - -e 's/hs_office_partner/hs_office_relationship/g' \ - -e 's/HsOfficePartner/HsOfficeRelationship/g' \ - -e 's/hsOfficePartner/hsOfficeRelationship/g' \ - -e 's/partner/relationship/g' \ - \ - -e 's/addPartner/addRelationship/g' \ - -e 's/listPartners/listRelationships/g' \ - -e 's/getPartnerByUuid/getRelationshipByUuid/g' \ - -e 's/patchPartner/patchRelationship/g' \ - -e 's/person/relHolder/g' \ - -e 's/registrationOffice/relType/g' \ - <$source >$target - -done - -exit - -cat >>src/main/resources/db/changelog/db.changelog-master.yaml <