From 69e2fc09a6f49a8181d3bb4d611ab38fb5e43692 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 9 Mar 2026 11:22:32 +0100 Subject: [PATCH] Story#5617: amend account module to Keycloak primary (#213) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/213 --- .aliases | 32 +- README.md | 36 +- bin/jwt-curl | 4 +- build.gradle.kts | 18 + doc/ideas/account-profiles-data-model.mermaid | 49 -- doc/ideas/accounts-data-model.mermaid | 29 ++ gradle.properties | 1 + .../hsadminng/HsadminNgApplication.java | 6 +- .../hsadminng/config/FakeJwtController.java | 6 +- .../HttpServletRequestBodyCachingFilter.java | 10 +- .../HttpServletRequestWithCachedBody.java | 33 -- .../hs/accounts/HsAccountController.java | 296 +++++++++++ .../hs/accounts/HsAccountEntity.java | 78 +++ .../hs/accounts/HsAccountRepository.java | 43 ++ .../hs/accounts/HsProfileController.java | 375 -------------- .../hs/accounts/HsProfileEntity.java | 133 ----- .../hs/accounts/HsProfileEntityPatcher.java | 37 -- .../hs/accounts/HsProfileRepository.java | 43 -- .../hsadminng/hs/accounts/HsProfileScope.java | 79 --- .../hs/accounts/HsProfileScopeController.java | 45 -- .../hs/accounts/HsProfileScopeRbacEntity.java | 55 -- .../HsProfileScopeRbacRepository.java | 24 - .../hs/accounts/HsProfileScopeRealEntity.java | 23 - .../HsProfileScopeRealRepository.java | 24 - .../accounts/ScopeResourceToEntityMapper.java | 70 --- .../person/HsOfficePersonController.java | 23 +- .../rbac/grant/RbacGrantService.java | 34 ++ .../rbac/role/RbacRoleRepository.java | 13 + .../hsadminng/rbac/role/RbacRoleService.java | 31 ++ .../hostsharing/hsadminng/repr/Stringify.java | 2 +- .../hsadminng/repr/Stringifyable.java | 8 + .../accounts/account-schemas.yaml | 77 +++ .../accounts/{profiles.yaml => accounts.yaml} | 22 +- .../accounts/accout-with-uuid.yaml | 48 ++ .../api-definition/accounts/api-mappings.yaml | 2 +- .../api-definition/accounts/api-paths.yaml | 16 +- .../api-definition/accounts/current.yaml | 4 +- .../accounts/profile-schemas.yaml | 124 ----- .../accounts/profile-with-uuid.yaml | 77 --- .../accounts/scope-schemas.yaml | 23 - .../api-definition/accounts/scopes.yaml | 21 - .../hs-office/hs-office-persons.yaml | 6 + src/main/resources/application.yml | 8 +- .../950-accounts/9500-hs-accounts-schema.sql | 2 +- .../950-accounts/9510-hs-accounts.sql | 72 +-- .../9513-hs-accounts-scope-rbac.md | 41 -- .../9519-hs-accounts-test-data.sql | 65 +-- .../resources/i18n/messages_de.properties | 9 +- .../resources/i18n/messages_en.properties | 9 +- .../resources/i18n/messages_fr.properties | 7 +- .../HsAccountControllerAcceptanceTest.java | 253 +++++++++ .../hs/accounts/HsAccountEntityUnitTest.java | 38 ++ ...> HsAccountRepositoryIntegrationTest.java} | 175 ++----- .../HsProfileControllerAcceptanceTest.java | 488 ------------------ .../HsProfileEntityPatcherUnitTest.java | 167 ------ .../hs/accounts/HsProfileEntityUnitTest.java | 79 --- .../HsProfileScopeControllerRestTest.java | 210 -------- .../HsProfileScopeRbacEntityUnitTest.java | 32 -- ...ileScopeRbacRepositoryIntegrationTest.java | 167 ------ .../HsProfileScopeRealEntityUnitTest.java | 32 -- ...ileScopeRealRepositoryIntegrationTest.java | 181 ------- .../AccountCanViewTheirOwnMemberships.java | 53 ++ .../AccountCanViewTheirOwnPerson.java | 42 ++ .../AccountCanViewTheirOwnRelations.java | 40 ++ .../scenarios/AccountScenarioTests.java | 232 +++++++++ .../scenarios/BaseAccountUseCase.java | 15 + .../scenarios/BaseProfileUseCase.java | 43 -- ...va => CreateAccountForExistingPerson.java} | 40 +- ...on.java => CreateAccountForNewPerson.java} | 38 +- .../accounts/scenarios/CurrentLoginUser.java | 7 +- .../accounts/scenarios/FetchRbacContext.java | 5 +- .../scenarios/ProfileScenarioTests.java | 186 ------- .../hs/accounts/scenarios/UpdateProfile.java | 65 --- ...ainSetupHostingAssetValidatorUnitTest.java | 5 +- ...sOfficePersonControllerAcceptanceTest.java | 15 + .../hsadminng/hs/scenarios/FakeLoginUser.java | 13 +- .../scenarios/MarkdownTableCellRenderer.java | 63 +++ .../hsadminng/hs/scenarios/ScenarioTest.java | 14 +- .../hsadminng/hs/scenarios/TestReport.java | 4 +- .../hsadminng/hs/scenarios/UseCase.java | 102 +++- .../hsadminng/repr/StringifyableUnitTest.java | 81 +++ .../hsadminng/test/JsonMatcher.java | 6 +- 82 files changed, 1813 insertions(+), 3371 deletions(-) delete mode 100644 doc/ideas/account-profiles-data-model.mermaid create mode 100644 doc/ideas/accounts-data-model.mermaid delete mode 100644 src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestWithCachedBody.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountController.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntity.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepository.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepository.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScope.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeController.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntity.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepository.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntity.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepository.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/hs/accounts/ScopeResourceToEntityMapper.java create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantService.java create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleService.java create mode 100644 src/main/resources/api-definition/accounts/account-schemas.yaml rename src/main/resources/api-definition/accounts/{profiles.yaml => accounts.yaml} (67%) create mode 100644 src/main/resources/api-definition/accounts/accout-with-uuid.yaml delete mode 100644 src/main/resources/api-definition/accounts/profile-schemas.yaml delete mode 100644 src/main/resources/api-definition/accounts/profile-with-uuid.yaml delete mode 100644 src/main/resources/api-definition/accounts/scope-schemas.yaml delete mode 100644 src/main/resources/api-definition/accounts/scopes.yaml delete mode 100644 src/main/resources/db/changelog/9-hs-global/950-accounts/9513-hs-accounts-scope-rbac.md create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntityUnitTest.java rename src/test/java/net/hostsharing/hsadminng/hs/accounts/{HsProfileRepositoryIntegrationTest.java => HsAccountRepositoryIntegrationTest.java} (63%) delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcherUnitTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeControllerRestTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntityUnitTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepositoryIntegrationTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntityUnitTest.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepositoryIntegrationTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnMemberships.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnPerson.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnRelations.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountScenarioTests.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseAccountUseCase.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseProfileUseCase.java rename src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/{CreateProfileForExistingPerson.java => CreateAccountForExistingPerson.java} (52%) rename src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/{CreateProfileForNewPerson.java => CreateAccountForNewPerson.java} (57%) delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java delete mode 100644 src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/UpdateProfile.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/scenarios/MarkdownTableCellRenderer.java create mode 100644 src/test/java/net/hostsharing/hsadminng/repr/StringifyableUnitTest.java diff --git a/.aliases b/.aliases index 96a0b970..570398a0 100644 --- a/.aliases +++ b/.aliases @@ -75,13 +75,37 @@ alias gw-importHostingAssets='importLegacyData importHostingAssets' function gradlewBootRun() { local serverPort=${1:-8080}; shift - local managementPort=${2:-$((serverPort + 1))}; shift - local additional_args="$@" + local managementPort + if [[ -n "${1:-}" && "${1}" != -* ]]; then + managementPort=$1 + shift + else + managementPort=$((serverPort + 1)) + fi + + # everything before `--` is treated as application args (go into --args="...") + # everything after `--` is treated as Gradle/task args (e.g. --debug-jvm, --stacktrace, --info, ...) + local -a app_args=() + while [[ $# -gt 0 && "${1}" != "--" ]]; do + app_args+=("$1") + shift + done + if [[ "${1:-}" == "--" ]]; then + shift + fi + local -a gradle_args=("$@") + + # preserve previous behavior: app args are appended as plain space-separated tokens + local additional_args="" + if [[ ${#app_args[@]} -gt 0 ]]; then + additional_args="$(printf ' %s' "${app_args[@]}")" + fi + unset HSADMINNG_JWT_ISSUER unset HSADMINNG_JWT_JWKS_URL unset HSADMINNG_JWT_TOKEN_URL set -x - ./gradlew bootRun --args="--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=${serverPort} --management.server.port=${managementPort} ${additional_args}" + ./gradlew bootRun "${gradle_args[@]}" --args="--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=${serverPort} --management.server.port=${managementPort}${additional_args}" set +x } alias gw-bootRun=gradlewBootRun @@ -91,7 +115,7 @@ alias podman-stop='systemctl --user disable --now podman.socket && systemctl --u alias podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true' alias gw=gradleWrapper -alias pg-sql-run='docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15.5-bookworm' +alias pg-sql-run='docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:17.7-trixie' alias pg-sql-stop='docker stop hsadmin-ng-postgres' alias pg-sql-start='docker container start hsadmin-ng-postgres' alias pg-sql-remove='docker rm hsadmin-ng-postgres' diff --git a/README.md b/README.md index a31e3a46..e9ffe2b7 100644 --- a/README.md +++ b/README.md @@ -87,20 +87,27 @@ If you have at least Docker and the Java JDK installed in appropriate versions a # if the container has been built already and you want to keep the data, run this: pg-sql-start -Next, compile and run the application with in dev-mode with all modules, test-data and fake-JWT-authentication:: +Next, compile and run the application with in dev-mode with all modules, test-data and fake-JWT-authentication usind the `gw-bootRun` alias: # on `localhost:8080` and the management server on `localhost:8081`: gw-bootRun - # there is also an alias which takes an optional port as an argument: - gw-bootRun 8888 + # you can also pass optional arguments: + gw-bootRun 8888 # will set the management port to 8888+1 = 8889 + gw-bootRun 8888 9999 # with explicit management port 9999 -The meaning of these profiles is: +At the beginning of the output, you'll see the full `./gradlew`-call like this: + +``` ++ ./gradlew bootRun '--args=--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=8080 --management.server.port=8081' +``` + +The meaning of the listed profiles is: - **dev**: the PostgreSQL users are created via Liquibase - **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server -- **complete**: all modules are started -- **test-data**: some test data inserted +- **complete**: all modules (rbac, office, account, hosting) are started +- **test-data**: some test data gets inserted at startup Now we can access the REST API, e.g. using curl. But you need to use JWT authentication. To make this a bit easier to handle, we use `bin/jwt-curl` (or `jwt-curl` alias). @@ -130,7 +137,7 @@ Make sure you replace `8080` with the port you used to run the application.` # the following command should return a JSON array with just all customers: jwt-curl GET http://localhost:8080/api/test/customers \ - | jq # just if `jq` is installed, to prettyprint the output + | jq` # just if `jq` is installed, to prettyprint the output # the following command should return a JSON array with just all packages visible for the admin of the customer yyy: jwt-curl ASSUME 'rbactest.customer#yyy:ADMIN' @@ -138,7 +145,7 @@ Make sure you replace `8080` with the port you used to run the application.` | jq jwt-curl UNASSUME - # add a new customer + # add a new customer (this is a test-are, not to confuse with a Hostsharing partner) jwt-curl POST \ -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \ http://localhost:8080/api/test/customers \ @@ -177,14 +184,14 @@ But the easiest way to run PostgreSQL is via Docker. Initially, pull an image compatible to the current PostgreSQL version of Hostsharing: - docker pull postgres:15.5-bookworm + docker pull postgres:17.7-trixie **⚠** If we switch the version, please also amend the documentation as well as the aliases file. Thanks! Create and run a container with the given PostgreSQL version: - docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15.5-bookworm + docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:17.7-trixie # or via alias: pg-sql-run @@ -705,14 +712,15 @@ These profiles mean: ### How to Run the Application in a Debugger -Add `' --debug-jvm` to the command line: - +Add `'-- --debug-jvm` to the command line ('...' stands any other args). +The `--debug-jvm` is a so-called *Gradle side knob, which goes outside of the `--args="..."` application arguments, +thus we separate it by `--`; this is treated by the `gw-bootRun` alias. ```sh -gw bootRun ... --debug-jvm +gw-bootRun ... -- --debug-jvm ``` -At the very beginning, the application is going to wait for a debugger with a message like this: +In the very beginning, the application is going to wait for a debugger with a message like this: > Listening for transport dt_socket at address: 5005 diff --git a/bin/jwt-curl b/bin/jwt-curl index 86f268a0..0489e231 100755 --- a/bin/jwt-curl +++ b/bin/jwt-curl @@ -14,11 +14,11 @@ if [ "$1" == "--trace" ]; then function doCurl() { set -x if [ -z "$HSADMINNG_JWT_ASSUME" ]; then - curl --fail-with-body \ + curl --no-progress-meter --show-error --fail-with-body \ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \ "$@" else - curl --fail-with-body \ + curl --no-progress-meter --show-error --fail-with-body \ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \ --header "assumed-roles: $HSADMINNG_JWT_ASSUME" \ "$@" diff --git a/build.gradle.kts b/build.gradle.kts index c9f0802a..97ac4120 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,7 @@ plugins { java id("org.springframework.boot") version "3.5.5" id("io.spring.dependency-management") version "1.1.7" // manages implicit dependencies + id("com.gorylenko.gradle-git-properties") version "2.5.0" // exposes git commit info via Actuator info endpoint id("io.openapiprocessor.openapi-processor") version "2023.2" // generates Controller-interface and resources from API-spec id("com.github.jk1.dependency-license-report") version "2.9" // checks dependency-license compatibility id("org.owasp.dependencycheck") version "12.1.1" // checks dependencies for known vulnerabilities @@ -39,6 +40,10 @@ plugins { id("com.github.ben-manes.versions") version "0.52.0" // determines which dependencies have updates } +springBoot { + buildInfo() +} + // HOWTO: find out which dependency versions are managed by Spring Boot: // https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html @@ -702,3 +707,16 @@ tasks.named("bootRun") { // Or always enable debug (remove the if condition) // jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005") } + +// Generate git.properties for Spring Boot Actuator /actuator/info +gitProperties { + // Keep output small but useful for build identification + keys = listOf( + "git.commit.id", + "git.commit.id.abbrev", + "git.branch", + "git.commit.time" + ) + // Allow builds from exported sources without .git + failOnNoGitDirectory = false +} diff --git a/doc/ideas/account-profiles-data-model.mermaid b/doc/ideas/account-profiles-data-model.mermaid deleted file mode 100644 index 22154c44..00000000 --- a/doc/ideas/account-profiles-data-model.mermaid +++ /dev/null @@ -1,49 +0,0 @@ -classDiagram - direction LR - -OfficePerson "1" o.. "*" Profile -Profile "1" o-- "1" RbacSubject - -Scope "1..n" --o "1" ScopeMapping -Profile "1..n" --o "1" ScopeMapping - -class Profile{ - +emailAdress: text - +smsNumber: text - +password: text - +totpSecrets: text - +phonePassword: text - -active: bool [r/w] - -globalUid: int [w/o] - -globalGid: int [w/o] -} - -class Scope{ - -type: Enum [SSH, Matrix, Mastodon, ...] - -qualifier: text -} - -class ScopeMapping{ - note for ScopeMapping "Assigns Profile to Scopes" -} - -class RbacSubject{ - +uuid: uuid - +name: text # == nickname -} - -class OfficePerson{ - +type: enum - +tradename: text - +title: text - +familyName: text - +givenName: text - +salutation: text -} - -style Scope fill:#00f,color:#fff -style ScopeMapping fill:#00f,color:#fff -style Profile fill:#00f,color:#fff - -style RbacSubject fill:#f96,color:#fff -style OfficePerson fill:#f66,color:#000 diff --git a/doc/ideas/accounts-data-model.mermaid b/doc/ideas/accounts-data-model.mermaid new file mode 100644 index 00000000..c041bfa1 --- /dev/null +++ b/doc/ideas/accounts-data-model.mermaid @@ -0,0 +1,29 @@ +classDiagram + direction LR + +OfficePerson "1" o.. "*" Account +Account "1" o-- "1" RbacSubject + +class Account{ + -globalUid: int [w/o] + -globalGid: int [w/o] +} + +class RbacSubject{ + +uuid: uuid + +name: text # == subjectName +} + +class OfficePerson{ + +type: enum + +tradename: text + +title: text + +familyName: text + +givenName: text + +salutation: text +} + +style Account fill:#00f,color:#fff + +style RbacSubject fill:#f96,color:#fff +style OfficePerson fill:#f66,color:#000 diff --git a/gradle.properties b/gradle.properties index 433cede1..0953e576 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ # Gradle Java Toolchain-support +org.gradle.toolchain.auto-download=true org.gradle.java.installations.auto-detect=true org.gradle.java.installations.auto-download=true # org.gradle.jvm.toolchain.install.adoptopenjdk.baseUri diff --git a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java index 59812797..2816aeb7 100644 --- a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java +++ b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java @@ -21,14 +21,14 @@ public class HsadminNgApplication { @Override public void addCorsMappings(CorsRegistry registry) { - // TODO: to enable testing, we should use Spring config + // TODO: to enable testing, we should use Spring config String allowedOrigins = System.getenv("ALLOWED_ORIGINS"); if (allowedOrigins == null || allowedOrigins.length() <= 1) { allowedOrigins = "/**"; } registry.addMapping("/api/**") - .allowedOrigins(allowedOrigins) - .allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE"); + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE"); } }; } diff --git a/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java b/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java index e58776ad..d037b131 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java +++ b/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import jakarta.servlet.http.HttpServletRequest; import java.util.Map; @@ -21,8 +22,9 @@ public class FakeJwtController { @PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @Timed("app.config.jwt.token") public ResponseEntity> token( - @RequestParam String username, - @RequestParam String password, + HttpServletRequest request, + @RequestParam(name = "username", required = false) String username, + @RequestParam(name = "password", required = false) String password, @RequestParam(defaultValue = "openid profile") String scope) { log.info("Fake JWT: Issuing token for user: {}", username); diff --git a/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCachingFilter.java b/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCachingFilter.java index fcca347c..1b498c7d 100644 --- a/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCachingFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestBodyCachingFilter.java @@ -1,12 +1,13 @@ package net.hostsharing.hsadminng.context; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + import java.io.IOException; @Component @@ -18,6 +19,7 @@ public class HttpServletRequestBodyCachingFilter extends OncePerRequestFilter { final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { - filterChain.doFilter(new HttpServletRequestWithCachedBody(request), response); + + filterChain.doFilter(new ContentCachingRequestWrapper(request), response); } } diff --git a/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestWithCachedBody.java b/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestWithCachedBody.java deleted file mode 100644 index 4af77a13..00000000 --- a/src/main/java/net/hostsharing/hsadminng/context/HttpServletRequestWithCachedBody.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.hostsharing.hsadminng.context; - -import org.springframework.util.StreamUtils; - -import jakarta.servlet.ServletInputStream; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStreamReader; - -public class HttpServletRequestWithCachedBody extends HttpServletRequestWrapper { - - private byte[] cachedBody; - - public HttpServletRequestWithCachedBody(HttpServletRequest request) throws IOException { - super(request); - final var requestInputStream = request.getInputStream(); - this.cachedBody = StreamUtils.copyToByteArray(requestInputStream); - } - - @Override - public ServletInputStream getInputStream() { - return new HttpServletRequestBodyCache(this.cachedBody); - } - - @Override - public BufferedReader getReader() { - ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody); - return new BufferedReader(new InputStreamReader(byteArrayInputStream)); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountController.java new file mode 100644 index 00000000..04c26ce2 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountController.java @@ -0,0 +1,296 @@ +package net.hostsharing.hsadminng.hs.accounts; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.AllArgsConstructor; +import lombok.val; +import net.hostsharing.hsadminng.accounts.generated.api.v1.api.AccountApi; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountInsertResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource; +import net.hostsharing.hsadminng.config.MessageTranslator; +import net.hostsharing.hsadminng.errors.ForbiddenException; +import net.hostsharing.hsadminng.errors.Validate; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; +import net.hostsharing.hsadminng.rbac.context.Context; +import net.hostsharing.hsadminng.rbac.grant.RbacGrantRepository; +import net.hostsharing.hsadminng.rbac.grant.RbacGrantService; +import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository; +import net.hostsharing.hsadminng.rbac.role.RbacRoleService; +import net.hostsharing.hsadminng.rbac.role.RbacRoleType; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; +import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ValidationException; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static java.util.Optional.of; + +@RestController +@PreAuthorize("isAuthenticated()") +@SecurityRequirement(name = "bearerAuth") +public class HsAccountController implements AccountApi { + + @Autowired + private Context context; + + @Autowired + private EntityManagerWrapper em; + + @Autowired + private StrictMapper mapper; + + @Autowired + private MessageTranslator messageTranslator; + + @Autowired + private HsOfficePersonRealRepository realPersonRepo; + + @Autowired + private HsAccountRepository accountRepo; + + @Autowired + private RbacSubjectRepository rbacSubjectRepo; + + @Autowired + private RbacRoleRepository rbacRoleRepo; + + @Autowired + private RbacGrantRepository rbacGrantRepo; + + @Autowired + private RbacRoleService rbacRoleService; + + @Autowired + private RbacGrantService rbacGrantService; + + @Override + @Transactional(readOnly = true) + @Timed("app.accounts.account.getSingleAccountByUuid") + public ResponseEntity getSingleAccountByUuid(final UUID accountUuid) { + + context.define(); // without assumed roles, otherwise we cannot access the real subject anymore + + val accountEntity = accountRepo.findByUuid(accountUuid); + if (accountEntity.isEmpty()) { + return ResponseEntity.notFound().build(); + } + val result = mapper.map( + accountEntity.get(), AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(result); + } + + @Override + @Transactional(readOnly = true) + @Timed("app.accounts.account.getListIfAccountByPersonUuid") + public ResponseEntity> getListIfAccount( + final String assumedRoles, + final UUID personUuid + ) { + context.assumeRoles(assumedRoles); + + val account = personUuid == null + ? accountRepo.findByCurrentSubject() + : findByPersonUuid(personUuid); + val result = mapper.mapList( + account, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(result); + } + + @Override + @Transactional + @Timed("app.accounts.account.postNewAccount") + public ResponseEntity postNewAccount( + final AccountInsertResource body + ) { + // Only with exactly 1 assumed role, we can do explicit grants, because the assumed role is used as 'grantor'. + // Otherwise, only the granting user could revoke the grant. + context.assumeRoles("rbac.global#global:ADMIN"); + + val originalLoginContext = new LoginContext(context); + + // TODO.spec: for now, only global admins can create new accounts, auto-creation has to be specified. which person? + if (!originalLoginContext.isGlobalAdmin) { + throw new ForbiddenException( + messageTranslator.translate( + //"account.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person", + "account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin", + originalLoginContext.subjectUuid)); + } + + // first create and save the subject to get its UUID + val newlySavedSubject = createSubject(body.getSubjectName()); + + // determine the assigned person while we still have global-admin privileges + val newAccountEntity = mapper.map( + body, HsAccountEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + validateOnCreate(originalLoginContext, newAccountEntity); + + // grant the person's ADMIN role to the new subject + rbacGrantService.grant(rbacRoleService.rbacRole(newAccountEntity.getPerson(), RbacRoleType.ADMIN)) + .to(newlySavedSubject); + + // switch to the new subject to get access to its own subject RBAC object + context.define("activate newly created self-service subject", null, body.getSubjectName(), null); + + // afterward, create and save the account entity with the subject's UUID + newAccountEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject + em.persist(newAccountEntity); // newAccountEntity.uuid == newlySavedSubject.uuid => do not use repository! + + // return the new account as a resource + val uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/accounts/accounts/{id}") + .buildAndExpand(newAccountEntity.getUuid()) + .toUri(); + val newAccountResource = mapper.map( + newAccountEntity, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(newAccountResource); + } + + @Override + @Transactional + @Timed("app.accounts.account.deleteAccountByUuid") + public ResponseEntity deleteAccountByUuid(final UUID accountUuid) { + context.define(); // without assumed roles, otherwise we cannot access the subject anymore + val accountEntity = em.getReference(HsAccountEntity.class, accountUuid); + validateOnDelete(accountEntity); + em.flush(); + em.remove(accountEntity); + em.remove(accountEntity.getSubject()); + return ResponseEntity.noContent().build(); + } + + @Override + @Transactional + @Timed("app.accounts.account.getCurrentLoginUser") + public ResponseEntity getCurrentLoginUser() { + + // define a context without assumed roles, otherwise we cannot access the subject anymore + context.define(); + + // fetch the data + val currentSubjectUuid = context.fetchCurrentSubjectUuid(); + val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); + val person = accountRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson(); + + final boolean isGlobalAdmin = context.isGlobalAdmin(); + + // finally, return the result + val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin); + return ResponseEntity.ok(result); + } + + private void validateOnCreate(final LoginContext originalLoginContext, final HsAccountEntity newAccountEntity) { + validateReferencedPersonToBeANaturalPerson(newAccountEntity); + } + + private void validateOnDelete(final HsAccountEntity current) { + // TODO.spec Task#5637: still needed? can the own account even be removed, even the last one? + } + + private void validateReferencedPersonToBeANaturalPerson(final HsAccountEntity accountEntity) { + val referredPerson = accountEntity.getPerson(); + if ( referredPerson.getPersonType() != HsOfficePersonType.NATURAL_PERSON) { + throw new ValidationException( + messageTranslator.translate( + "account.only-natural-persons-allowed-but-{0}-is-{1}", + referredPerson.getUuid(), referredPerson.getPersonType().name())); + } + } + + private RealSubjectEntity createSubject(final String subjectName) { + val rbacSubjectEntity = RbacSubjectEntity.builder().name(subjectName).build(); + val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity); + return em.find(RealSubjectEntity.class, newRbacSubject.getUuid()); + } + + private List findByPersonUuid(final UUID personUuid) { + val person = realPersonRepo.findByUuid(personUuid).orElseThrow( + () -> new EntityNotFoundException( + messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid) + ) + + ); + return accountRepo.findByPerson(person); + } + + + private CurrentLoginUserResource currentLoginUserResponse( + final RbacSubjectEntity currentSubject, + final HsOfficePerson person, + final boolean isGlobalAdmin) { + val result = new CurrentLoginUserResource(); + result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class)); + result.setPerson(mapper.map(person, HsOfficePersonResource.class)); + result.setGlobalAdmin(isGlobalAdmin); + return result; + } + + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + of(entity.getSubject()).ifPresent( + subject -> resource.setSubjectName(subject.getName()) + ); + of(entity.getPerson()).ifPresent( + person -> resource.setPerson( + mapper.map(person, HsOfficePersonResource.class) + ) + ); + }; + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + + Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid()); + if ( resource.getPersonUuid() != null) { + entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( + () -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid()) + )); + } else { + entity.setPerson(realPersonRepo.save( + mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class) + ) ); + } + + val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow( + () -> new EntityNotFoundException( + messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid()) + ) + ); + entity.setPerson(person); + }; + + @AllArgsConstructor + private class LoginContext { + final HsAccountEntity account; + final boolean isGlobalAdmin; + final UUID subjectUuid; + + public LoginContext(final Context context) { + subjectUuid = context.fetchCurrentSubjectUuid(); + account = accountRepo.findByUuid(subjectUuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "subject " + context.fetchCurrentSubject() + " has no account")); + isGlobalAdmin = context.isGlobalAdmin(); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntity.java new file mode 100644 index 00000000..c67f7ee5 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntity.java @@ -0,0 +1,78 @@ +package net.hostsharing.hsadminng.hs.accounts; + +import jakarta.persistence.*; +import jakarta.validation.ValidationException; + +import lombok.*; +import net.hostsharing.hsadminng.hash.LdapArgon2Hash; +import net.hostsharing.hsadminng.hash.LdapSshaHash; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists +import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; +import net.hostsharing.hsadminng.repr.Stringify; +import net.hostsharing.hsadminng.repr.Stringifyable; + +import java.util.UUID; + +import static net.hostsharing.hsadminng.repr.Stringify.stringify; + +@Entity +@Table(schema = "hs_accounts", name = "account") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HsAccountEntity implements BaseEntity, Stringifyable { + + protected static Stringify stringify = stringify(HsAccountEntity.class, "account") + .withProp(e -> e.getSubject().getName()) + .quotedValues(false); + + @Id + private UUID uuid; + + @MapsId + @OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid") + // Must be the real subject, not the RBAC-subject, + // so that representative persons can access accounts+subjects of represented persons. + // Otherwise, we would also need to allow RBAC grants to subject roles. + // This also means that each access has to be checked explicitly (same subject or represented subject). + private RealSubjectEntity subject; + + @ManyToOne(optional = false, fetch = FetchType.EAGER) + @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") + private HsOfficePersonRealEntity person; // TODO.spec: Do we need ReBAC-Support for AccountEntity? + + @Version + private int version; + + @Column + private Integer globalUid; + + @Column + private Integer globalGid; + + public void setSubject(final RealSubjectEntity subject) { + this.uuid = subject.getUuid(); + this.subject = subject; + } + + @Override + public String toShortString() { + return subject.getName(); + } + + @Override + public String toString() { + return stringify.apply(this); + } + + private static void validatePasswordHash(final String passwordHash) { + + if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) { + throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP"); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepository.java new file mode 100644 index 00000000..21bdbbd1 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepository.java @@ -0,0 +1,43 @@ +package net.hostsharing.hsadminng.hs.accounts; + +import io.micrometer.core.annotation.Timed; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; +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 HsAccountRepository extends Repository { + + @Timed("app.login.account.repo.findByUuid") + Optional findByUuid(final UUID uuid); + + @Timed("app.login.account.repo.findByPerson") + List findByPerson(final HsOfficePerson personUuid); + + @Timed("app.login.account.repo.findByCurrentSubject") + @Query(nativeQuery = true, value = """ + WITH RECURSIVE + same_person AS ( + SELECT own_account.person_uuid + FROM hs_accounts.account own_account + WHERE own_account.uuid = rbac.currentSubjectUuid() + ), + represented_persons AS ( + SELECT relation.anchorUuid person_uuid + FROM hs_office.relation relation + WHERE relation.type = 'REPRESENTATIVE' + AND relation.holderUuid IN (SELECT person_uuid FROM same_person) + ) + SELECT DISTINCT account.* + FROM hs_accounts.account account + WHERE account.person_uuid IN (SELECT person_uuid FROM same_person) + OR account.person_uuid IN (SELECT person_uuid FROM represented_persons) + """) + List findByCurrentSubject(); + + @Timed("app.login.account.repo.save") + HsAccountEntity save(final HsAccountEntity entity); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java deleted file mode 100644 index 6afb5a8a..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java +++ /dev/null @@ -1,375 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import io.micrometer.core.annotation.Timed; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import lombok.AllArgsConstructor; -import lombok.val; -import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ProfileApi; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileInsertResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource; -import net.hostsharing.hsadminng.config.MessageTranslator; -import net.hostsharing.hsadminng.errors.ForbiddenException; -import net.hostsharing.hsadminng.errors.Validate; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; -import net.hostsharing.hsadminng.mapper.StrictMapper; -import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; -import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; - -import jakarta.persistence.EntityNotFoundException; -import jakarta.validation.ValidationException; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.stream.Collectors; - -import static java.util.Optional.of; - -@RestController -@PreAuthorize("isAuthenticated()") -@SecurityRequirement(name = "bearerAuth") -public class HsProfileController implements ProfileApi { - - @Autowired - private Context context; - - @Autowired - private EntityManagerWrapper em; - - @Autowired - private StrictMapper mapper; - - @Autowired - private ScopeResourceToEntityMapper scopeMapper; - - @Autowired - private MessageTranslator messageTranslator; - - @Autowired - private HsOfficePersonRealRepository realPersonRepo; - - @Autowired - private HsProfileRepository profileRepo; - - @Autowired - private RbacSubjectRepository rbacSubjectRepo; - - @Override - @Transactional(readOnly = true) - @Timed("app.accounts.profile.getSingleProfileByUuid") - public ResponseEntity getSingleProfileByUuid(final UUID profileUuid) { - - context.define(); // without assumed roles, otherwise we cannot access the subject anymore - - val profileEntity = profileRepo.findByUuid(profileUuid); - if (profileEntity.isEmpty()) { - return ResponseEntity.notFound().build(); - } - val result = mapper.map( - profileEntity.get(), ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.ok(result); - } - - @Override - @Transactional(readOnly = true) - @Timed("app.accounts.profile.getListOfProfileByPersonUuid") - public ResponseEntity> getListOfProfile( - final String assumedRoles, - final UUID personUuid - ) { - context.assumeRoles(assumedRoles); - - val profile = personUuid == null - ? profileRepo.findByCurrentSubject() - : findByPersonUuid(personUuid); - val result = mapper.mapList( - profile, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.ok(result); - } - - @Override - @Transactional - @Timed("app.accounts.profile.postNewProfile") - public ResponseEntity postNewProfile( - final ProfileInsertResource body - ) { - context.define(); // without assumed roles, otherwise we cannot access the subject anymore - final LoginContext originalLoginContext = new LoginContext(context); - - // first create and save the subject to get its UUID - val newlySavedSubject = createSubject(body.getNickname()); - - // switch to the new subject to get access to its own subject RBAC object - context.define("activate newly created self-service subject", null, body.getNickname(), null); - - // afterward, create and save the profile entity with the subject's UUID - val newProfileEntity = mapper.map( - body, HsProfileEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - validateOnCreate(originalLoginContext, newProfileEntity); - - newProfileEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject - em.persist(newProfileEntity); // newProfileEntity.uuid == newlySavedSubject.uuid => do not use repository! - - // return the new profile as a resource - val uri = - MvcUriComponentsBuilder.fromController(getClass()) - .path("/api/hs/accounts/profiles/{id}") - .buildAndExpand(newProfileEntity.getUuid()) - .toUri(); - val newProfileResource = mapper.map( - newProfileEntity, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.created(uri).body(newProfileResource); - } - - @Override - @Transactional - @Timed("app.accounts.profile.deleteProfileByUuid") - public ResponseEntity deleteProfileByUuid(final UUID profileUuid) { - context.define(); // without assumed roles, otherwise we cannot access the subject anymore - val profileEntity = em.getReference(HsProfileEntity.class, profileUuid); - profileEntity.getScopes().clear(); - validateOnDelete(profileEntity); - em.flush(); - em.remove(profileEntity); - em.remove(profileEntity.getSubject()); - return ResponseEntity.noContent().build(); - } - - @Override - @Transactional - @Timed("app.accounts.profile.patchProfile") - public ResponseEntity patchProfile( - final UUID profileUuid, - final ProfilePatchResource body - ) { - context.define(); // without assumed roles, otherwise we cannot access the subject anymore - final LoginContext originalLoginContext = new LoginContext(context); - - val current = profileRepo.findByUuid(profileUuid).orElseThrow(); - - validateBeforePatch(originalLoginContext, current, body); - new HsProfileEntityPatcher(scopeMapper, current).apply(body); - validateOnUpdate(current); - - val saved = profileRepo.save(current); - val mapped = mapper.map( - saved, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); - return ResponseEntity.ok(mapped); - } - - @Override - @Transactional - @Timed("app.accounts.profile.getCurrentLoginUser") - public ResponseEntity getCurrentLoginUser() { - - // define a context without assumed roles, otherwise we cannot access the subject anymore - context.define(); - - // fetch the data - val currentSubjectUuid = context.fetchCurrentSubjectUuid(); - val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); - val person = profileRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson(); - - final boolean isGlobalAdmin = context.isGlobalAdmin(); - - // finally, return the result - val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin); - return ResponseEntity.ok(result); - } - - private void validateBeforePatch(final LoginContext originalLoginContext, final HsProfileEntity current, final ProfilePatchResource body) { - validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, current); - - if (!context.isGlobalAdmin() && !current.isActive() && body.getActive()) - throw new ForbiddenException("Only global admins are allowed to activate an inactive profile"); - } - - private void validateOnCreate(final LoginContext originalLoginContext, final HsProfileEntity newProfileEntity) { - validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, newProfileEntity); - validateNormalUsersOnlyAccessPublicScopes(newProfileEntity); - validateNaturalPersonRequirementOfScopes(newProfileEntity); - } - - private void validateOnUpdate(final HsProfileEntity current) { - validateNormalUsersOnlyAccessPublicScopes(current); - validateNaturalPersonRequirementOfScopes(current); - validateOwnHsadminProfileMustNotBeRemoved(current); - } - - private void validateOnDelete(final HsProfileEntity profileEntity) { - validateOwnHsadminProfileMustNotBeRemoved(profileEntity); - } - - private void validateReferencedPersonToBeRepresentedByLoginUserPerson(final LoginContext originalLoginContext, final HsProfileEntity profileEntity) { - if (originalLoginContext.isGlobalAdmin) { - return; - } - val referredPersonUuid = profileEntity.getPerson().getUuid(); - val loginPersonUuid = originalLoginContext.profile.getPerson().getUuid(); - val representedPersonUuids = realPersonRepo.findPersonsRepresentedByPersonWithUuid(loginPersonUuid) - .stream().map(HsOfficePerson::getUuid).toList(); - if ( !representedPersonUuids.contains(referredPersonUuid)) { - throw new ForbiddenException( - messageTranslator.translate( - "profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person", - loginPersonUuid)); - } - } - - private void validateNormalUsersOnlyAccessPublicScopes(final HsProfileEntity newProfileEntity) { - val forbiddenScopes = newProfileEntity.getScopes().stream() - .filter(c -> !c.isPublicAccess() && !context.isGlobalAdmin() ) - .toList(); - if (!forbiddenScopes.isEmpty()) { - throw new ForbiddenException( - messageTranslator.translate( - "profile.access-denied-for-scopes-{0}", - toDisplay(forbiddenScopes) - )); - } - } - - private void validateNaturalPersonRequirementOfScopes(final HsProfileEntity newProfileEntity) { - if (newProfileEntity.getPerson().getPersonType().equals(HsOfficePersonType.NATURAL_PERSON)) { - return; - } - val scopesWhichRequireNaturalPerson = newProfileEntity.getScopes().stream() - .filter(HsProfileScope::isOnlyForNaturalPersons) - .toList(); - if (!scopesWhichRequireNaturalPerson.isEmpty()) { - throw new ValidationException( - messageTranslator.translate( - "profile.scope-requires-natural-person-{0}", - toDisplay(scopesWhichRequireNaturalPerson) - )); - } - } - - private void validateOwnHsadminProfileMustNotBeRemoved(final HsProfileEntity newProfileEntity) { - if (!newProfileEntity.getSubject().getUuid().equals(context.fetchCurrentSubjectUuid())) { - return; - } - val hsadminProfileScope = newProfileEntity.getScopes().stream() - .filter(HsProfileScope::isHsadminScope) - .toList(); - if (hsadminProfileScope.isEmpty()) { - throw new ValidationException( - messageTranslator.translate( - "profile.own-hsadmin-profile-must-not-be-removed" - )); - } - } - - private static String toDisplay(final List scopesWhichRequireNaturalPerson) { - return scopesWhichRequireNaturalPerson.stream() - .map(HsProfileScope::toShortString) - .sorted() - .map(s -> "'" + s + "'") - .collect(Collectors.joining(", ")); - } - - private RealSubjectEntity createSubject(final String nickname) { - val rbacSubjectEntity = RbacSubjectEntity.builder().name(nickname).build(); - val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity); - return em.find(RealSubjectEntity.class, newRbacSubject.getUuid()); - } - - private List findByPersonUuid(final UUID personUuid) { - val person = realPersonRepo.findByUuid(personUuid).orElseThrow( - () -> new EntityNotFoundException( - messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid) - ) - - ); - return profileRepo.findByPerson(person); - } - - - private CurrentLoginUserResource currentLoginUserResponse( - final RbacSubjectEntity currentSubject, - final HsOfficePerson person, - final boolean isGlobalAdmin) { - val result = new CurrentLoginUserResource(); - result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class)); - result.setPerson(mapper.map(person, HsOfficePersonResource.class)); - result.setGlobalAdmin(isGlobalAdmin); - return result; - } - - final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { - of(entity.getSubject()).ifPresent( - subject -> resource.setNickname(subject.getName()) - ); - of(entity.getPerson()).ifPresent( - person -> resource.setPerson( - mapper.map(person, HsOfficePersonResource.class) - ) - ); - - resource.setScopes(mapToValidScopeResources(entity)); - }; - - private List mapToValidScopeResources(final HsProfileEntity entity) { - var allScopes = mapper.mapList(entity.getScopes().stream().toList(), ScopeResource.class); - return allScopes.stream() - .filter(scope -> !scope.getOnlyForNaturalPersons() || - entity.getPerson().getPersonType() == HsOfficePersonType.NATURAL_PERSON) - .toList(); - } - - final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { - - Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid()); - if ( resource.getPersonUuid() != null) { - entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( - () -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid()) - )); - } else { - entity.setPerson(realPersonRepo.save( - mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class) - ) ); - } - - val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow( - () -> new EntityNotFoundException( - messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid()) - ) - ); - entity.setPerson(person); - entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes())); - entity.setPassword(resource.getPassword()); - }; - - @AllArgsConstructor - private class LoginContext { - final HsProfileEntity profile; - final boolean isGlobalAdmin; - - public LoginContext(final Context context) { - val subjectUuid = context.fetchCurrentSubjectUuid(); - profile = profileRepo.findByUuid(subjectUuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, - "subject " + context.fetchCurrentSubject() + " has no profile")); - isGlobalAdmin = context.isGlobalAdmin(); - } - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java deleted file mode 100644 index 4493c354..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java +++ /dev/null @@ -1,133 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import jakarta.persistence.*; -import jakarta.validation.ValidationException; - -import lombok.*; -import net.hostsharing.hsadminng.hash.HashGenerator; -import net.hostsharing.hsadminng.hash.LdapArgon2Hash; -import net.hostsharing.hsadminng.hash.LdapSshaHash; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; -import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists -import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; -import net.hostsharing.hsadminng.repr.Stringify; -import net.hostsharing.hsadminng.repr.Stringifyable; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; - -import static jakarta.persistence.CascadeType.MERGE; -import static jakarta.persistence.CascadeType.REFRESH; -import static net.hostsharing.hsadminng.repr.Stringify.stringify; - -@Entity -@Table(schema = "hs_accounts", name = "profile") -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class HsProfileEntity implements BaseEntity, Stringifyable { - - protected static Stringify stringify = stringify(HsProfileEntity.class, "profile") - .withProp(HsProfileEntity::isActive) - .withProp(HsProfileEntity::getEmailAddress) - .withProp(HsProfileEntity::getTotpSecrets) - .withProp(HsProfileEntity::getPhonePassword) - .withProp(HsProfileEntity::getSmsNumber) - .quotedValues(false); - - @Id - private UUID uuid; - - @MapsId - @OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid") - // Must be the real subject, so that representative persons can access profiles+subjects of represented persons. - // Otherwise, we would also need to allow RBAC grants to subject roles. - // This also means that each access has to be checked explicitly (same subject or represented subject). - private RealSubjectEntity subject; - - @ManyToOne(optional = false, fetch = FetchType.EAGER) - @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") - private HsOfficePersonRealEntity person; // TODO.impl: add RBAC-Support to ProfileEntity, see Story # - - @Version - private int version; - - @Column - private boolean active; - - @Column - private Integer globalUid; - - @Column - private Integer globalGid; - - @Column(name = "password_hash") - private String passwordHash; - - @Column - private List totpSecrets; - - @Column - private String phonePassword; - - @Column - private String emailAddress; - - @Column - private String smsNumber; - - @OneToMany(fetch = FetchType.EAGER, cascade = { MERGE, REFRESH }) - @JoinTable( - name = "scope_mapping", schema = "hs_accounts", - joinColumns = @JoinColumn(name = "profile_uuid", referencedColumnName = "uuid"), - inverseJoinColumns = @JoinColumn(name = "scope_uuid", referencedColumnName = "uuid") - ) - private Set scopes; - - public Set getScopes() { - if ( scopes == null ) { - scopes = new HashSet<>(); - } - return scopes; - } - - public void setSubject(final RealSubjectEntity subject) { - this.uuid = subject.getUuid(); - this.subject = subject; - } - - public void setPassword(final String password) { - setPasswordHash( - HashGenerator.fromEnv("ACCOUNT_PROFILE_PASSWORD_HASH_ALGORITHM", "{SSHA}") - .withRandomSalt().hash(password)); - } - - public void setPasswordHash(final String passwordHash) { - if (passwordHash != null) { - validatePasswordHash(passwordHash); - } - this.passwordHash = passwordHash; - } - - @Override - public String toShortString() { - return active + ":" + emailAddress + ":" + globalUid; - } - - @Override - public String toString() { - return stringify.apply(this); - } - - private static void validatePasswordHash(final String passwordHash) { - - if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) { - throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP"); - } - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java deleted file mode 100644 index 083d7605..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource; -import net.hostsharing.hsadminng.mapper.EntityPatcher; -import net.hostsharing.hsadminng.mapper.OptionalFromJson; - -import java.util.Optional; - -public class HsProfileEntityPatcher implements EntityPatcher { - - private ScopeResourceToEntityMapper scopeMapper; - private final HsProfileEntity entity; - - public HsProfileEntityPatcher(final ScopeResourceToEntityMapper scopeMapper, final HsProfileEntity entity) { - this.scopeMapper = scopeMapper; - this.entity = entity; - } - - @Override - public void apply(final ProfilePatchResource resource) { - Optional.ofNullable(resource.getActive()) - .ifPresent(entity::setActive); - OptionalFromJson.of(resource.getEmailAddress()) - .ifPresent(entity::setEmailAddress); - Optional.ofNullable(resource.getTotpSecrets()) - .ifPresent(entity::setTotpSecrets); - OptionalFromJson.of(resource.getSmsNumber()) - .ifPresent(entity::setSmsNumber); - Optional.ofNullable(resource.getPassword()) - .ifPresent(entity::setPassword); - OptionalFromJson.of(resource.getPhonePassword()) - .ifPresent(entity::setPhonePassword); - if (resource.getScopes() != null) { - scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes()); - } - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepository.java deleted file mode 100644 index 8bb01fb9..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepository.java +++ /dev/null @@ -1,43 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import io.micrometer.core.annotation.Timed; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; -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 HsProfileRepository extends Repository { - - @Timed("app.login.profile.repo.findByUuid") - Optional findByUuid(final UUID uuid); - - @Timed("app.login.profile.repo.findByPerson") - List findByPerson(final HsOfficePerson personUuid); - - @Timed("app.login.profile.repo.findByCurrentSubject") - @Query(nativeQuery = true, value = """ - WITH RECURSIVE - same_person AS ( - SELECT own_profile.person_uuid - FROM hs_accounts.profile own_profile - WHERE own_profile.uuid = rbac.currentSubjectUuid() - ), - represented_persons AS ( - SELECT relation.anchorUuid person_uuid - FROM hs_office.relation relation - WHERE relation.type = 'REPRESENTATIVE' - AND relation.holderUuid IN (SELECT person_uuid FROM same_person) - ) - SELECT DISTINCT profile.* - FROM hs_accounts.profile profile - WHERE profile.person_uuid IN (SELECT person_uuid FROM same_person) - OR profile.person_uuid IN (SELECT person_uuid FROM represented_persons) - """) - List findByCurrentSubject(); - - @Timed("app.login.profile.repo.save") - HsProfileEntity save(final HsProfileEntity entity); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScope.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScope.java deleted file mode 100644 index c39bd783..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScope.java +++ /dev/null @@ -1,79 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.SuperBuilder; -import net.hostsharing.hsadminng.persistence.BaseEntity; -import net.hostsharing.hsadminng.repr.Stringify; -import net.hostsharing.hsadminng.repr.Stringifyable; - -import java.util.UUID; - -import static net.hostsharing.hsadminng.repr.Stringify.stringify; -import static net.hostsharing.hsadminng.repr.Symbol.symbol; - -@Getter -@Setter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true) -@MappedSuperclass -public abstract class HsProfileScope implements Stringifyable, BaseEntity { - - private static Stringify stringify = stringify(HsProfileScope.class, "scope") - .withProp(HsProfileScope::getType) - .withProp(HsProfileScope::getQualifier) - .withProp( - HsProfileScope::isOnlyForNaturalPersons, - value -> value ? symbol("NP-ONLY") : null) - .withProp( - HsProfileScope::isPublicAccess, - value -> value ? symbol("PUBLIC") : symbol("INTERNAL")) - .quotedValues(false) - .withSeparator(":"); - - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - @Column(name = "uuid", nullable = false, updatable = false) - private UUID uuid; - - @NotNull - @Column - private int version; - - @NotNull - @Column(name = "type", length = 16) - private String type; - - @Column(name = "qualifier", length = 80) - private String qualifier; - - @Column(name = "only_for_natural_persons") - private boolean onlyForNaturalPersons; - - @Column(name = "public_access") - private boolean publicAccess; - - public boolean isHsadminScope() { - return "HSADMIN".equals(type); - } - - @Override - public String toShortString() { - return type + (qualifier != null ? ":" + qualifier : ""); - } - - @Override - public String toString() { - return stringify.apply(this); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeController.java deleted file mode 100644 index 6cabba3b..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeController.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import java.util.List; - -import io.micrometer.core.annotation.Timed; -import lombok.val; -import net.hostsharing.hsadminng.config.NoSecurityRequirement; -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ScopesApi; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource; -import net.hostsharing.hsadminng.mapper.StrictMapper; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@NoSecurityRequirement -public class HsProfileScopeController implements ScopesApi { - - @Autowired - private Context context; - - @Autowired - private StrictMapper mapper; - - @Autowired - private HsProfileScopeRbacRepository scopeRepo; - - @Override - @Transactional(readOnly = true) - @Timed("app.accounts.scopes.getListOfScopes") - public ResponseEntity> getListOfScopes(final String assumedRoles) { - if (SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) { - context.assumeRoles(assumedRoles); - } - val isGlobalAdmin = context.isGlobalAdmin(); - final var scopes = scopeRepo.findAll().stream().filter( - scope -> scope.isPublicAccess() || isGlobalAdmin - ).toList(); - final var result = mapper.mapList(scopes, ScopeResource.class); - return ResponseEntity.ok(result); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntity.java deleted file mode 100644 index c6170b9f..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntity.java +++ /dev/null @@ -1,55 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.AttributeOverrides; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.SuperBuilder; -import net.hostsharing.hsadminng.rbac.generator.RbacSpec; -import net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL; - -import java.io.IOException; - -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.GLOBAL; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Permission.*; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.GUEST; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.OWNER; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.ADMIN; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.REFERRER; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.WITHOUT_IMPLICIT_GRANTS; -import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.rbacViewFor; - -@Entity -@Table(schema = "hs_accounts", name = "scope") // TODO_impl: RBAC rules for _rv do not yet work properly -@SuperBuilder(toBuilder = true) -@Getter -@Setter -@NoArgsConstructor -@AttributeOverrides({ - @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) -}) -public class HsProfileScopeRbacEntity extends HsProfileScope { - - // TODO_impl: RBAC rules for _rv do not yet work properly (remove the X) - public static RbacSpec rbacX() { - return rbacViewFor("profileScope", HsProfileScopeRbacEntity.class) - .withIdentityView(SQL.projection("type || ':' || qualifier")) - .withRestrictedViewOrderBy(SQL.expression("type || ':' || qualifier")) - .withoutUpdatableColumns() - .createRole(OWNER, WITHOUT_IMPLICIT_GRANTS) - .createSubRole(ADMIN, WITHOUT_IMPLICIT_GRANTS) - .createSubRole(REFERRER, WITHOUT_IMPLICIT_GRANTS) - .toRole(GLOBAL, ADMIN).grantPermission(INSERT) - .toRole(GLOBAL, ADMIN).grantPermission(DELETE) - .toRole(GLOBAL, GUEST).grantPermission(SELECT); - } - - // TODO_impl: RBAC rules for _rv do not yet work properly (remove the X) - public static void mainX(String[] args) throws IOException { - rbacX().generateWithBaseFileName("9-hs-global/950-accounts/9513-hs-profile-rbac"); - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepository.java deleted file mode 100644 index b4b6a204..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import io.micrometer.core.annotation.Timed; -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 HsProfileScopeRbacRepository extends Repository { - - @Timed("app.accounts.scope.repo.findAll.rbac") - List findAll(); - - @Timed("app.accounts.scope.repo.findByUuid.rbac") - Optional findByUuid(final UUID id); - - @Timed("app.accounts.scope.repo.findByTypeAndQualifier.rbac") - Optional findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier); - - @Timed("app.accounts.scope.repo.save.rbac") - HsProfileScopeRbacEntity save(final HsProfileScopeRbacEntity entity); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntity.java deleted file mode 100644 index ea8a6080..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntity.java +++ /dev/null @@ -1,23 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.AttributeOverrides; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.SuperBuilder; - -@Entity -@Table(schema = "hs_accounts", name = "scope") -@SuperBuilder(toBuilder = true) -@Getter -@Setter -@NoArgsConstructor -@AttributeOverrides({ - @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) -}) -public class HsProfileScopeRealEntity extends HsProfileScope { -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepository.java deleted file mode 100644 index 4748d94c..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import io.micrometer.core.annotation.Timed; -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 HsProfileScopeRealRepository extends Repository { - - @Timed("app.account.scope.repo.findAll.real") - List findAll(); - - @Timed("app.account.scope.repo.findByUuid.real") - Optional findByUuid(final UUID id); - - @Timed("app.account.scope.repo.findByTypeAndQualifier.real") - Optional findByTypeAndQualifier(@NotNull String type, @NotNull String qualifier); - - @Timed("app.account.scope.repo.save.real") - HsProfileScopeRealEntity save(final HsProfileScopeRealEntity entity); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/ScopeResourceToEntityMapper.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/ScopeResourceToEntityMapper.java deleted file mode 100644 index 6d8fdd87..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/ScopeResourceToEntityMapper.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityNotFoundException; - -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource; -import net.hostsharing.hsadminng.config.MessageTranslator; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -@Service -public class ScopeResourceToEntityMapper { - - private final EntityManager em; - private final MessageTranslator messageTranslator; - - @Autowired - public ScopeResourceToEntityMapper(final EntityManager em, final MessageTranslator messageTranslator) { - this.em = em; - this.messageTranslator = messageTranslator; - } - - public Set mapProfileToScopeEntities( - final List resources - ) { - final var entities = new HashSet(); - syncProfileScopeEntities(resources, entities); - return entities; - } - - public void syncProfileScopeEntities( - final List resources, - final Set entities - ) { - final var resourceUuids = resources.stream() - .map(ScopeResource::getUuid) - .collect(Collectors.toSet()); - - final var entityUuids = entities.stream() - .map(HsProfileScopeRealEntity::getUuid) - .collect(Collectors.toSet()); - - entities.removeIf(e -> !resourceUuids.contains(e.getUuid())); - - for (final var resource : resources) { - if (!entityUuids.contains(resource.getUuid())) { - final var existingScopeEntity = em.find(HsProfileScopeRealEntity.class, resource.getUuid()); - if (existingScopeEntity == null) { - throw new EntityNotFoundException( - messageTranslator.translate( - "general.{0}-{1}-not-found-or-not-accessible", - "profile uuid", resource.getUuid())); - } - if ((resource.getType() != null && !existingScopeEntity.getType().equals(resource.getType())) || - (resource.getQualifier() != null && !existingScopeEntity.getQualifier().equals(resource.getQualifier()))) { - throw new EntityNotFoundException( - messageTranslator.translate( - "profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}", - existingScopeEntity, resource)); - } - entities.add(existingScopeEntity); - } - } - } -} 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 302ab3d4..d9bf428c 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 @@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hs.office.person; import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import net.hostsharing.hsadminng.mapper.StrictMapper; -import net.hostsharing.hsadminng.rbac.context.Context; +import lombok.val; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.rbac.context.Context; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -38,14 +40,23 @@ public class HsOfficePersonController implements HsOfficePersonsApi { public ResponseEntity> getListOfPersons( final String assumedRoles, final String name, + final HsOfficePersonTypeResource type, final UUID representedByPersonUuid) { context.assumeRoles(assumedRoles); - final var entities = representedByPersonUuid != null - ? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid) - : personRepo.findPersonByOptionalNameLike(name); + val personType = type != null ? HsOfficePersonType.valueOf(type.name()) : null; + // @formatter:off + val entities = ( + representedByPersonUuid != null + ? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid) + : personRepo.findPersonByOptionalNameLike(name) + ).stream() + // TODO.perf: this could be moved into the queries to improve the performance a bit + .filter(p -> personType == null || p.getPersonType() == personType) + .toList(); + // @formatter:on - final var resources = mapper.mapList(entities, HsOfficePersonResource.class); + val resources = mapper.mapList(entities, HsOfficePersonResource.class); return ResponseEntity.ok(resources); } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantService.java b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantService.java new file mode 100644 index 00000000..8a201051 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantService.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.rbac.grant; + +import net.hostsharing.hsadminng.rbac.role.RbacRoleEntity; +import net.hostsharing.hsadminng.rbac.subject.Subject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class RbacGrantService { + + @Autowired + private RbacGrantRepository rbacGrantRepo; + + public class RbacRoleGranter{ + private final RbacRoleEntity role; + + public RbacRoleGranter(final RbacRoleEntity role) { + this.role = role; + } + + public void to(final Subject subject) { + rbacGrantRepo.save(RbacGrantEntity.builder() + .grantedRoleUuid(role.getUuid()) + .granteeSubjectUuid(subject.getUuid()) + .assumed(true) + .build()); + } + } + + public RbacRoleGranter grant(final RbacRoleEntity role) { + return new RbacRoleGranter(role); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java index aef8a910..7c03a749 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java @@ -24,6 +24,19 @@ public interface RbacRoleRepository extends Repository { @Timed("app.rbac.roles.repo.findByRoleName") RbacRoleEntity findByRoleName(String roleName); + @Timed("app.rbac.roles.repo.findByObjectUuidAndRoleType") + @Query(value = """ + SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName + FROM rbac.role_rv rev + WHERE rev.objectuuid = :objectUuid + AND rev.roletype = cast(:roleType as rbac.roletype) + """, nativeQuery = true) + RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, String roleType); + + default RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, RbacRoleType roleType) { + return findByObjectUuidAndRoleType(objectUuid, roleType.name()); + } + @Timed("app.rbac.roles.repo.fetchAssumedRoles") @Query(value = """ SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleService.java b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleService.java new file mode 100644 index 00000000..597e5be4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleService.java @@ -0,0 +1,31 @@ +package net.hostsharing.hsadminng.rbac.role; + +import lombok.val; +import net.hostsharing.hsadminng.persistence.BaseEntity; +import net.hostsharing.hsadminng.repr.Stringifyable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + + +/// Just a APIs to programmatically handle RBAC roles. +@Service +public class RbacRoleService{ + + @Autowired + private RbacRoleRepository rbacRoleRepo; + + public RbacRoleEntity rbacRole(final BaseEntity rbacEntity, final RbacRoleType roleType) { + val personAdminRole = rbacRoleRepo.findByObjectUuidAndRoleType(rbacEntity.getUuid(), roleType); + if (personAdminRole == null) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "no ADMIN role not found for %s %s".formatted( + Stringifyable.toShortString(rbacEntity), + rbacEntity.getUuid() + )); + } + return personAdminRole; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java b/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java index b4920ead..77dfdf93 100644 --- a/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java +++ b/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java @@ -126,7 +126,7 @@ public final class Stringify { private class Property { String name; Function getter; - Function mapper; // FIXME: better generics? + Function mapper; // TODO.impl: better generics? Property(String name, Function getter) { this(name, getter, v -> v); diff --git a/src/main/java/net/hostsharing/hsadminng/repr/Stringifyable.java b/src/main/java/net/hostsharing/hsadminng/repr/Stringifyable.java index 397a6c2b..8c2aeb78 100644 --- a/src/main/java/net/hostsharing/hsadminng/repr/Stringifyable.java +++ b/src/main/java/net/hostsharing/hsadminng/repr/Stringifyable.java @@ -1,6 +1,14 @@ package net.hostsharing.hsadminng.repr; +import net.hostsharing.hsadminng.persistence.BaseEntity; + public interface Stringifyable { + static String toShortString(final BaseEntity entity) { + return entity instanceof Stringifyable stringifyableEntity + ? stringifyableEntity.toShortString() + : entity.getUuid().toString(); + } + String toShortString(); } diff --git a/src/main/resources/api-definition/accounts/account-schemas.yaml b/src/main/resources/api-definition/accounts/account-schemas.yaml new file mode 100644 index 00000000..a260ae15 --- /dev/null +++ b/src/main/resources/api-definition/accounts/account-schemas.yaml @@ -0,0 +1,77 @@ + +components: + + schemas: + + CurrentLoginUser: + type: object + properties: + subject: + $ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject' + person: + $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + globalAdmin: + type: boolean + + Account: + type: object + properties: + uuid: + type: string + format: uuid + person: + $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + subjectName: + type: string + description: " + Two parts separated by a single '-'. + The first part is the Keycloak realm name of 3-9 chars length. + The second part a account name local to that realm, which is 3-32 chars in length. + Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_', + but '.'/'_' must be followed by a letter or digit. + " + pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$' + globalUid: + type: number + globalGid: + type: number + required: + - uuid + - subjectName + - person + - globalUid + - globalGid + additionalProperties: false + + AccountInsert: + type: object + properties: + person.uuid: + type: string + format: uuid + person: + $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' + subjectName: + type: string + description: " + Two parts separated by a single '-'. + The first part is the Keycloak realm name of 3-9 chars length. + The second part a account name local to that realm, which is 3-32 chars in length. + Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_', + but '.'/'_' must be followed by a letter or digit. + " + pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$' + globalUid: + type: number + globalGid: + type: number + required: + - subjectName + - globalUid + - globalGid + # soon we might need to be able to use this: + # https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410 + # For now we just describe the conditionally required properties: + description: + Either `person.uuid` or `person` need to be given. + additionalProperties: false diff --git a/src/main/resources/api-definition/accounts/profiles.yaml b/src/main/resources/api-definition/accounts/accounts.yaml similarity index 67% rename from src/main/resources/api-definition/accounts/profiles.yaml rename to src/main/resources/api-definition/accounts/accounts.yaml index 5e4c3dec..b385f81e 100644 --- a/src/main/resources/api-definition/accounts/profiles.yaml +++ b/src/main/resources/api-definition/accounts/accounts.yaml @@ -1,9 +1,9 @@ get: - summary: Returns a list of all profile. - description: Returns the list of all profile which are visible to the current subject or any of it's assumed roles. + summary: Returns a list of all account. + description: Returns the list of all account which are visible to the current subject or any of it's assumed roles. tags: - - profile - operationId: getListOfProfile + - account + operationId: getListIfAccount parameters: - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: personUuid @@ -12,7 +12,7 @@ get: schema: type: string format: uuid - description: The UUID of the person, whose profile are to be fetched. Or null, if all profile of the login-use should be fetched. + description: The UUID of the person, whose account are to be fetched. Or null, if all account of the login-use should be fetched. responses: "200": description: OK @@ -21,31 +21,31 @@ get: schema: type: array items: - $ref: 'profile-schemas.yaml#/components/schemas/Profile' + $ref: 'account-schemas.yaml#/components/schemas/Account' "401": $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": $ref: 'error-responses.yaml#/components/responses/Forbidden' post: - summary: Adds a new profile. + summary: Adds a new account. tags: - - profile - operationId: postNewProfile + - account + operationId: postNewAccount requestBody: description: A JSON object describing the new credential. required: true content: application/json: schema: - $ref: 'profile-schemas.yaml#/components/schemas/ProfileInsert' + $ref: 'account-schemas.yaml#/components/schemas/AccountInsert' responses: "201": description: Created content: 'application/json': schema: - $ref: 'profile-schemas.yaml#/components/schemas/Profile' + $ref: 'account-schemas.yaml#/components/schemas/Account' "401": $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": diff --git a/src/main/resources/api-definition/accounts/accout-with-uuid.yaml b/src/main/resources/api-definition/accounts/accout-with-uuid.yaml new file mode 100644 index 00000000..5b36e228 --- /dev/null +++ b/src/main/resources/api-definition/accounts/accout-with-uuid.yaml @@ -0,0 +1,48 @@ +get: + tags: + - account + description: 'Fetch a single account its uuid, if visible for the current subject.' + operationId: getSingleAccountByUuid + parameters: + - name: accountUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the account to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'account-schemas.yaml#/components/schemas/Account' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - account + description: 'Delete a single account identified by its uuid, if permitted for the current subject.' + operationId: deleteAccountByUuid + parameters: + - name: accountUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the account 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/accounts/api-mappings.yaml b/src/main/resources/api-definition/accounts/api-mappings.yaml index fe611dff..0c6bef93 100644 --- a/src/main/resources/api-definition/accounts/api-mappings.yaml +++ b/src/main/resources/api-definition/accounts/api-mappings.yaml @@ -13,5 +13,5 @@ map: - type: string:uuid => java.util.UUID paths: - /api/hs/accounts/profiles/{profileUuid}: + /api/hs/accounts/accounts/{accountUuid}: null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/accounts/api-paths.yaml b/src/main/resources/api-definition/accounts/api-paths.yaml index 9cecc5c8..33addbb1 100644 --- a/src/main/resources/api-definition/accounts/api-paths.yaml +++ b/src/main/resources/api-definition/accounts/api-paths.yaml @@ -13,17 +13,11 @@ paths: /api/hs/accounts/current: $ref: "current.yaml" - # Scopes + # Account - /api/hs/accounts/scopes: - $ref: "scopes.yaml" + /api/hs/accounts/accounts/{accountUuid}: + $ref: "accout-with-uuid.yaml" - - # Profile - - /api/hs/accounts/profiles/{profileUuid}: - $ref: "profile-with-uuid.yaml" - - /api/hs/accounts/profiles: - $ref: "profiles.yaml" + /api/hs/accounts/accounts: + $ref: "accounts.yaml" diff --git a/src/main/resources/api-definition/accounts/current.yaml b/src/main/resources/api-definition/accounts/current.yaml index c73ed64f..8ff13425 100644 --- a/src/main/resources/api-definition/accounts/current.yaml +++ b/src/main/resources/api-definition/accounts/current.yaml @@ -2,7 +2,7 @@ get: summary: Currently logged in user data. description: Returns information about the currently logged in user. tags: - - profile + - account operationId: getCurrentLoginUser responses: "200": @@ -10,7 +10,7 @@ get: content: 'application/json': schema: - $ref: 'profile-schemas.yaml#/components/schemas/CurrentLoginUser' + $ref: 'account-schemas.yaml#/components/schemas/CurrentLoginUser' "401": $ref: 'error-responses.yaml#/components/responses/Unauthorized' "403": diff --git a/src/main/resources/api-definition/accounts/profile-schemas.yaml b/src/main/resources/api-definition/accounts/profile-schemas.yaml deleted file mode 100644 index 26a5aa9d..00000000 --- a/src/main/resources/api-definition/accounts/profile-schemas.yaml +++ /dev/null @@ -1,124 +0,0 @@ - -components: - - schemas: - - CurrentLoginUser: - type: object - properties: - subject: - $ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject' - person: - $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' - globalAdmin: - type: boolean - - Profile: - type: object - properties: - uuid: - type: string - format: uuid - person: - $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' - nickname: - type: string - pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname - emailAddress: - type: string - smsNumber: - type: string - totpSecrets: - type: array - items: - type: string - phonePassword: - type: string - active: - type: boolean - globalUid: - type: number - globalGid: - type: number - scopes: - type: array - items: - $ref: 'scope-schemas.yaml#/components/schemas/Scope' - required: - - uuid - - active - - scopes - additionalProperties: false - - ProfilePatch: - type: object - properties: - emailAddress: - type: string - nullable: true - smsNumber: - type: string - nullable: true - totpSecrets: - type: array - items: - type: string - password: - type: string - minLength: 8 - description: plaintext password or valid hash - phonePassword: - type: string - nullable: true - active: - type: boolean - scopes: - type: array - items: - $ref: 'scope-schemas.yaml#/components/schemas/Scope' - additionalProperties: false - - ProfileInsert: - type: object - properties: - person.uuid: - type: string - format: uuid - person: - $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert' - nickname: - type: string - pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname - emailAddress: - type: string - smsNumber: - type: string - active: - type: boolean - globalUid: - type: number - globalGid: - type: number - password: - type: string - minLength: 8 - description: plaintext password or valid hash - phonePassword: - type: string - totpSecrets: - type: array - items: - type: string - scopes: - type: array - items: - $ref: 'scope-schemas.yaml#/components/schemas/Scope' - required: - - nickname - - active - # soon we might need to be able to use this: - # https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410 - # For now we just describe the conditionally required properties: - description: - Either `person.uuid` or `person` need to be given. - additionalProperties: false diff --git a/src/main/resources/api-definition/accounts/profile-with-uuid.yaml b/src/main/resources/api-definition/accounts/profile-with-uuid.yaml deleted file mode 100644 index ddb08d93..00000000 --- a/src/main/resources/api-definition/accounts/profile-with-uuid.yaml +++ /dev/null @@ -1,77 +0,0 @@ -get: - tags: - - profile - description: 'Fetch a single profile its uuid, if visible for the current subject.' - operationId: getSingleProfileByUuid - parameters: - - name: profileUuid - in: path - required: true - schema: - type: string - format: uuid - description: UUID of the profile to fetch. - responses: - "200": - description: OK - content: - 'application/json': - schema: - $ref: 'profile-schemas.yaml#/components/schemas/Profile' - - "401": - $ref: 'error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: 'error-responses.yaml#/components/responses/Forbidden' - -patch: - tags: - - profile - description: 'Updates a single profile identified by its uuid, if permitted for the current subject.' - operationId: patchProfile - parameters: - - name: profileUuid - in: path - required: true - schema: - type: string - format: uuid - requestBody: - content: - 'application/json': - schema: - $ref: 'profile-schemas.yaml#/components/schemas/ProfilePatch' - responses: - "200": - description: OK - content: - 'application/json': - schema: - $ref: 'profile-schemas.yaml#/components/schemas/Profile' - "401": - $ref: 'error-responses.yaml#/components/responses/Unauthorized' - "403": - $ref: 'error-responses.yaml#/components/responses/Forbidden' - -delete: - tags: - - profile - description: 'Delete a single profile identified by its uuid, if permitted for the current subject.' - operationId: deleteProfileByUuid - parameters: - - name: profileUuid - in: path - required: true - schema: - type: string - format: uuid - description: UUID of the profile 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/accounts/scope-schemas.yaml b/src/main/resources/api-definition/accounts/scope-schemas.yaml deleted file mode 100644 index 08aabb14..00000000 --- a/src/main/resources/api-definition/accounts/scope-schemas.yaml +++ /dev/null @@ -1,23 +0,0 @@ - -components: - - schemas: - - Scope: - type: object - properties: - uuid: - type: string - format: uuid - type: - type: string - maxLength: 16 - qualifier: - type: string - maxLength: 80 - onlyForNaturalPersons: - type: boolean - publicAccess: - type: boolean - required: - - uuid diff --git a/src/main/resources/api-definition/accounts/scopes.yaml b/src/main/resources/api-definition/accounts/scopes.yaml deleted file mode 100644 index 878ba007..00000000 --- a/src/main/resources/api-definition/accounts/scopes.yaml +++ /dev/null @@ -1,21 +0,0 @@ -get: - summary: Returns a list of all accessible scopes. - description: Returns the list of all scopes which are visible to the current subject or any of it's assumed roles. - tags: - - scopes - operationId: getListOfScopes - parameters: - - $ref: 'auth.yaml#/components/parameters/assumedRoles' - responses: - "200": - description: OK - content: - 'application/json': - schema: - type: array - items: - $ref: 'scope-schemas.yaml#/components/schemas/Scope' - "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-office/hs-office-persons.yaml b/src/main/resources/api-definition/hs-office/hs-office-persons.yaml index e699de96..ba9cdcfd 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 @@ -12,6 +12,12 @@ get: schema: type: string description: Prefix of caption to filter the results. + - name: type + in: query + required: false + schema: + $ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonType' + description: Filter the results by person type. - name: representedByPersonUuid in: query required: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d73adc4a..b9a2700a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,12 @@ management: server: port: 8081 address: 127.0.0.1 + info: + build: + enabled: true + git: + enabled: true + mode: full endpoints: web: exposure: @@ -96,5 +102,3 @@ spring: jwt: issuer-uri: "http://localhost:${server.port}/fake-jwt" jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json" - - diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9500-hs-accounts-schema.sql b/src/main/resources/db/changelog/9-hs-global/950-accounts/9500-hs-accounts-schema.sql index cac77a3f..c712345d 100644 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9500-hs-accounts-schema.sql +++ b/src/main/resources/db/changelog/9-hs-global/950-accounts/9500-hs-accounts-schema.sql @@ -2,7 +2,7 @@ -- ============================================================================ ---changeset michael.hoennig:hs-profile-SCHEMA endDelimiter:--// +--changeset michael.hoennig:hs-accounts-SCHEMA endDelimiter:--// -- ---------------------------------------------------------------------------- CREATE SCHEMA hs_accounts; --// diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql b/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql index 9b246c1a..06b90060 100644 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql +++ b/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql @@ -2,76 +2,18 @@ -- ============================================================================ ---changeset michael.hoennig:hs-profile-PROFILE-TABLE endDelimiter:--// +--changeset michael.hoennig:hs-accounts-ACCOUNT-TABLE endDelimiter:--// -- ---------------------------------------------------------------------------- -create table hs_accounts.profile +create table hs_accounts.account ( uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred, version int not null default 0, person_uuid uuid not null references hs_office.person(uuid), - active bool, global_uid int unique, -- w/o - global_gid int unique, -- w/o - - password_hash text, - totp_secrets text[], - phone_password text, - email_address text, - sms_number text -); ---// - - --- ============================================================================ ---changeset michael.hoennig:hs-profile-scope-SCOPE-TABLE endDelimiter:--// --- ---------------------------------------------------------------------------- - -create table hs_accounts.scope -( - uuid uuid PRIMARY KEY, - version int not null default 0, - - type varchar(16), - qualifier varchar(80), - - only_for_natural_persons boolean default false, - - public_access boolean default false, - - unique (type, qualifier) -); ---// - - --- ============================================================================ ---changeset michael.hoennig:hs-profile-SCOPE-IMMUTABLE-TRIGGER endDelimiter:--// --- ---------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION hs_accounts.prevent_scope_update() -RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION 'Updates to hs_accounts.scope are not allowed.'; -END; -$$ LANGUAGE plpgsql; - --- Trigger to enforce immutability -CREATE TRIGGER scope_immutable_trigger -BEFORE UPDATE ON hs_accounts.scope -FOR EACH ROW EXECUTE FUNCTION hs_accounts.prevent_scope_update(); ---// - - --- ============================================================================ ---changeset michael.hoennig:hs_accounts-SCOPE-MAPPING endDelimiter:--// --- ---------------------------------------------------------------------------- - -create table hs_accounts.scope_mapping -( - uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - profile_uuid uuid references hs_accounts.profile(uuid) ON DELETE CASCADE, - scope_uuid uuid references hs_accounts.scope(uuid) ON DELETE RESTRICT + global_gid int unique -- w/o ); --// @@ -80,16 +22,12 @@ create table hs_accounts.scope_mapping --changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--// -- ---------------------------------------------------------------------------- -call base.create_journal('hs_accounts.scope_mapping'); -call base.create_journal('hs_accounts.scope'); -call base.create_journal('hs_accounts.profile'); +call base.create_journal('hs_accounts.account'); --// -- ============================================================================ --changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--// -- ---------------------------------------------------------------------------- -call base.tx_create_historicization('hs_accounts.scope_mapping'); -call base.tx_create_historicization('hs_accounts.scope'); -call base.tx_create_historicization('hs_accounts.profile'); +call base.tx_create_historicization('hs_accounts.account'); --// diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9513-hs-accounts-scope-rbac.md b/src/main/resources/db/changelog/9-hs-global/950-accounts/9513-hs-accounts-scope-rbac.md deleted file mode 100644 index 443c9f55..00000000 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9513-hs-accounts-scope-rbac.md +++ /dev/null @@ -1,41 +0,0 @@ -### rbac profileContext - -This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. - -```mermaid -%%{init:{'flowchart':{'htmlLabels':false}}}%% -flowchart TB - -subgraph profileContext["`**profileContext**`"] - direction TB - style profileContext fill:#dd4901,stroke:#274d6e,stroke-width:8px - - subgraph profileContext:roles[ ] - style profileContext:roles fill:#dd4901,stroke:white - - role:profileContext:OWNER[[profileContext:OWNER]] - role:profileContext:ADMIN[[profileContext:ADMIN]] - role:profileContext:REFERRER[[profileContext:REFERRER]] - end - - subgraph profileContext:permissions[ ] - style profileContext:permissions fill:#dd4901,stroke:white - - perm:profileContext:INSERT{{profileContext:INSERT}} - perm:profileContext:UPDATE{{profileContext:UPDATE}} - perm:profileContext:DELETE{{profileContext:DELETE}} - perm:profileContext:SELECT{{profileContext:SELECT}} - end -end - -%% granting roles to roles -role:profileContext:OWNER ==> role:profileContext:ADMIN -role:profileContext:ADMIN ==> role:profileContext:REFERRER - -%% granting permissions to roles -role:rbac.global:ADMIN ==> perm:profileContext:INSERT -role:rbac.global:ADMIN ==> perm:profileContext:UPDATE -role:rbac.global:ADMIN ==> perm:profileContext:DELETE -role:rbac.global:REFERRER ==> perm:profileContext:SELECT - -``` diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql b/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql index a6133c8c..95857d56 100644 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql +++ b/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql @@ -2,7 +2,7 @@ -- ============================================================================ ---changeset michael.hoennig:hs_accounts-profile-TEST-DATA context:!without-test-data endDelimiter:--// +--changeset michael.hoennig:hs_accounts-account-TEST-DATA context:!without-test-data endDelimiter:--// -- ---------------------------------------------------------------------------- do language plpgsql $$ @@ -15,13 +15,6 @@ declare userDrewSubjectUuid uuid; personDrewUuid uuid; - - scope_HSADMIN_prod hs_accounts.scope; - scope_SSH_internal hs_accounts.scope; - scope_SSH_external hs_accounts.scope; - scope_MATRIX_internal hs_accounts.scope; - scope_MATRIX_external hs_accounts.scope; - begin call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN'); @@ -32,57 +25,11 @@ begin userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org'); personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew'); - -- Add test scopes - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod', true, true) - RETURNING * INTO scope_HSADMIN_prod; - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('22222222-2222-2222-2222-222222222222', 'SSH', 'internal', true, false) - RETURNING * INTO scope_SSH_internal; - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('33333333-3333-3333-3333-333333333333', 'SSH', 'external', false, true) - RETURNING * INTO scope_SSH_external; - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('44444444-4444-4444-4444-444444444444', 'MATRIX', 'internal', true, false) - RETURNING * INTO scope_MATRIX_internal; - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('55555555-5555-5555-5555-555555555555', 'MATRIX', 'external', true, true) - RETURNING * INTO scope_MATRIX_external; - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('66666666-6666-6666-6666-666666666666', 'MASTODON', 'external', false, true); - INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES - ('77777777-7777-7777-7777-777777777777', 'BBB', 'external', false, true); - --- grant general access to public credential scopes --- TODO_impl: RBAC rules for _rv do not yet work properly --- call rbac.grantPermissiontoRole( --- rbac.createPermission(context_HSADMIN_prod.uuid, 'SELECT'), --- rbac.global_GUEST()); --- call rbac.grantPermissiontoRole( --- rbac.createPermission(context_SSH_internal.uuid, 'SELECT'), --- rbac.global_ADMIN()); --- call rbac.grantPermissionToRole( --- rbac.createPermission(context_MATRIX_internal.uuid, 'SELECT'), --- rbac.global_ADMIN()); --- call rbac.grantRoleToRole(hs_accounts.scope_REFERRER(context_SSH_internal), rbac.global_ADMIN()); --- call rbac.grantRoleToRole(hs_accounts.scope_REFERRER(context_MATRIX_internal), rbac.global_ADMIN()); - - -- Add test profile (linking to assumed rbac.subject UUIDs) - INSERT INTO hs_accounts.profile (uuid, version, person_uuid, active, global_uid, global_gid, totp_secrets, phone_password, email_address, sms_number) VALUES - ( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, ARRAY['otp-secret-1a', 'otp-secret-1b'], 'phone-pw-1', 'alex@example.com', '111-222-3333'), - ( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666'), - ( userDrewSubjectUuid, 0, personDrewUuid, true, 1003, 1003, ARRAY['otp-secret-3'], 'phone-pw-3', 'drew@example.org', '999-888-7777'); - - -- Map profile to contexts - INSERT INTO hs_accounts.scope_mapping (profile_uuid, scope_uuid) VALUES - (superuserAlexSubjectUuid, scope_HSADMIN_prod.uuid), - (superuserFranSubjectUuid, scope_HSADMIN_prod.uuid), - (userDrewSubjectUuid, scope_HSADMIN_prod.uuid), - (superuserAlexSubjectUuid, scope_SSH_internal.uuid), - (superuserFranSubjectUuid, scope_SSH_internal.uuid), - (userDrewSubjectUuid, scope_SSH_external.uuid), - (superuserAlexSubjectUuid, scope_MATRIX_internal.uuid), - (superuserFranSubjectUuid, scope_MATRIX_internal.uuid); + -- Add test account (linking to assumed rbac.subject UUIDs) + INSERT INTO hs_accounts.account (uuid, version, person_uuid, global_uid, global_gid) VALUES + ( superuserAlexSubjectUuid, 0, personAlexUuid, 1001, 1001), + ( superuserFranSubjectUuid, 0, personFranUuid, 1002, 1002), + ( userDrewSubjectUuid, 0, personDrewUuid, 1003, 1003); end; $$; --// diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties index a7e97fa6..06343237 100644 --- a/src/main/resources/i18n/messages_de.properties +++ b/src/main/resources/i18n/messages_de.properties @@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" nicht gefunden general.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht zugänglich general.but-is=ist aber -# profile validations -profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existierender Gültigkeitsbereich {0} passt nicht zum angegebenen {1} -profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert -profile.access-denied-for-scopes-{0}=Zugriff auf Geltungsbereich verweigert: {0} -profile.scope-requires-natural-person-{0}=Geltungsbereich verlangt eine natürliche Person: {0} -profile.own-hsadmin-profile-must-not-be-removed=die eigenen hsadmin-Profile dürfen nicht entfernt werden +# account validations +account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Zugriff verweigert: Neue Accounts können nur von globalen Admins angelegt werden, {0} ist kein solcher +account.only-natural-persons-allowed-but-{0}-is-{1}=Nur natürliche Personen sind erlaubt, aber {0} ist {1} # office.coop-shares office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1} diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties index 76aea01d..10ff8f5e 100644 --- a/src/main/resources/i18n/messages_en.properties +++ b/src/main/resources/i18n/messages_en.properties @@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" not found general.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessible general.but-is=but is -# profile validations -profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1} -profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person -profile.access-denied-for-scopes-{0}=scope-access denied: {0} -profile.scope-requires-natural-person-{0}=scope requires natural person: {0} -profile.own-hsadmin-profile-must-not-be-removed=own hsadmin-profile must not be removed +# account validations +account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Access denied: new accounts can only be created by global admins, {0} is not +account.only-natural-persons-allowed-but-{0}-is-{1}=only natural persons allowed, but {0} is {1} # office.coop-shares office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1} diff --git a/src/main/resources/i18n/messages_fr.properties b/src/main/resources/i18n/messages_fr.properties index 94f7b30e..e62826ad 100644 --- a/src/main/resources/i18n/messages_fr.properties +++ b/src/main/resources/i18n/messages_fr.properties @@ -11,11 +11,8 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" non trouvé ou non accessi general.but-is=mais c'est # profile validations -profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1} -profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=accès refusé : personUuid "{0}" non représenté par la personne actuellement connectée -profile.access-denied-for-scopes-{0}=accès au domaine d'application refusé : {0} -profile.scope-requires-natural-person-{0}=le domaine d'application requiert une personne physique : {0} -profile.own-hsadmin-profile-must-not-be-removed=suppression des identifiants hsadmin propres interdite +account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Accès refusé : seuls les administrateurs globaux peuvent créer de nouveaux comptes, {0} ne l’est pas +account.only-natural-persons-allowed-but-{0}-is-{1}=seulement personnes physiques sont accepté, mais {0} est {1} # office.coop-shares office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=pour le type de transaction {0}, shareCount doit être positif mais est {1} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java new file mode 100644 index 00000000..a1e17ce6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java @@ -0,0 +1,253 @@ +package net.hostsharing.hsadminng.hs.accounts; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import lombok.val; +import net.hostsharing.hsadminng.rbac.context.Context; +import net.hostsharing.hsadminng.hs.accounts.HsAccountEntity.HsAccountEntityBuilder; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; +import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.function.Consumer; + +import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer; +import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON; +import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON; +import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +@Tag("generalIntegrationTest") +@Transactional +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@ActiveProfiles("fake-jwt") +// too complex database interaction for just a RestTest, thus a fully integrated test +class HsAccountControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + @LocalServerPort + Integer port; + + @Autowired + Context context; + + @Autowired + RbacSubjectRepository rbacSubjectRepo; + + @Autowired + HsOfficePersonRealRepository realPersonRepo; + + @Autowired + HsAccountRepository accountRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @BeforeEach + void setUp() { + context.define("superuser-alex@hostsharing.net"); + } + + @Nested + class GetCurrentUser { + + @Test + void shouldFetchCurrentLoginUser() throws Exception { + // given + context.define("superuser-alex@hostsharing.net"); + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer("superuser-alex@hostsharing.net")) + .port(port) + .when() + .get("http://localhost/api/hs/accounts/current") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("subject.name", equalTo("superuser-alex@hostsharing.net")) + .body("globalAdmin", equalTo(true)); + // @formatter:on + } + } + + @Nested + class GetAccountByUuid { + + @Test + void shouldGetAccountByUuid() { + // given + val legalPerson = givenLegalPerson("selfregistered-user-drew@hostsharing.org"); + val accountEntity = givenNewAccount("selfregistered-user-drew@hostsharing.org", + "test-subject1", legalPerson, builder -> { + }); + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer(accountEntity.getSubject().getName())) + .port(port) + .when() + .get("http://localhost/api/hs/accounts/accounts/" + accountEntity.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("$", lenientlyEquals(""" + { + "person": { + "personType": "LEGAL_PERSON", + "tradeName": "Test Company", + "salutation": null, + "title": null, + "givenName": null, + "familyName": null + }, + "subjectName": "test-subject1", + "globalUid": null, + "globalGid": null + } + """)); + // @formatter:on + } + } + + @Nested + class PostNewAccount { + + @Test + void shouldRejectCreatingAccountForUnrepresentedPerson() { + // given + val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org"); + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "person.uuid": "%s", + "subjectName": "new-user", + "globalUid": 30001, + "globalGid": 40001 + } + """.formatted(testPerson.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/accounts/accounts") + .then().log().all().assertThat() + .statusCode(403) + .contentType("application/json") + .body("message", containsString("wird von der eingeloggten Person nicht repräsentiert")); + // @formatter:on + } + + @Test + void shouldRejectCreatingAccountForNonNaturalPerson() { + // given + val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst(); + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer("superuser-alex@hostsharing.net")) + .header("Accept-Language", "de") + .contentType(ContentType.JSON) + .body(""" + { + "person.uuid": "%s", + "subjectName": "new-user", + "globalUid": 30001, + "globalGid": 40001 + } + """.formatted(firstGmbHPerson.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/accounts/accounts") + .then().log().all().assertThat() + .statusCode(400) + .contentType("application/json") + .body("message", + containsString("Nur natürliche Personen sind erlaubt, aber ${personUuid} ist LEGAL_PERSON" + .replace("${personUuid}", firstGmbHPerson.getUuid().toString()))); + // @formatter:on + } + } + + // TODO.spec Task#5637: add @Nested class DeleteAccount and tests for delete, when we have a spec + + // Helper methods + + private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(LEGAL_PERSON) + .tradeName("Test Company") + .build())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(NATURAL_PERSON) + .familyName("Test") + .givenName("User") + .build())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() + .personType(NATURAL_PERSON) + .familyName("Test") + .givenName("Person") + .build())); + }).returnedValue(); + } + + private HsAccountEntity givenNewAccount( + final String executingSubjectName, + final String newSubjectName, final HsOfficePersonRealEntity person, + final Consumer modifier + ) { + return jpaAttempt.transacted(() -> { + context.define(executingSubjectName); + + // only RbacSubject entities can be created + val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder() + .name(newSubjectName) + .build()); + + context.define(subject.getName()); + val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid()); + val accountBuilder = HsAccountEntity.builder() + .person(attachedPerson) + .subject(em.find(RealSubjectEntity.class, subject.getUuid())); + modifier.accept(accountBuilder); + return toCleanup(accountRepo.save(accountBuilder.build())); + }).assertSuccessful().returnedValue(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntityUnitTest.java new file mode 100644 index 00000000..6c4ca9dd --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountEntityUnitTest.java @@ -0,0 +1,38 @@ +package net.hostsharing.hsadminng.hs.accounts; + +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; +import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsAccountEntityUnitTest { + + static final HsAccountEntity GIVEN_ACCOUNT_ENTITY = HsAccountEntity.builder() + .uuid(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .subject( + RealSubjectEntity.builder().uuid(UUID.randomUUID()).name("test-subject").build()) + .person( + HsOfficePersonRealEntity.builder() + .personType(HsOfficePersonType.NATURAL_PERSON) + .familyName("Miller") + .givenName("John") + .build() + ) + .globalUid(10001) + .globalUid(20002) + .build(); + + @Test + void toShortStringContainsJustTheSubjectName() { + assertThat(GIVEN_ACCOUNT_ENTITY.toShortString()).isEqualTo("test-subject"); + } + + @Test + void toStringContainsJustTheSubjectNam() { + assertThat(GIVEN_ACCOUNT_ENTITY.toString()).isEqualTo("account(test-subject)"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepositoryIntegrationTest.java similarity index 63% rename from src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java rename to src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepositoryIntegrationTest.java index 4aae66a6..68718848 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountRepositoryIntegrationTest.java @@ -12,7 +12,6 @@ import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import org.hibernate.TransientObjectException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -32,12 +31,11 @@ import java.util.Set; import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.REPRESENTATIVE; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; @DataJpaTest @Tag("generalIntegrationTest") @Import({ Context.class, JpaAttempt.class }) -class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { +class HsAccountRepositoryIntegrationTest extends ContextBasedTestWithCleanup { private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; private static final String SUPERUSER_FRAN_SUBJECT_NAME = "superuser-fran@hostsharing.net"; @@ -58,10 +56,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { private HsOfficePersonRealRepository personRepo; @Autowired - private HsProfileRepository profileRepository; - - @Autowired - private HsProfileScopeRealRepository scopeRealRepo; + private HsAccountRepository accountRepository; // fetched UUIDs from test-data private RealSubjectEntity alexSubject; @@ -82,7 +77,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @Test public void historizationIsAvailable() { // given - final String nativeQuerySql = "select * from hs_accounts.profile_hv"; + final String nativeQuerySql = "select * from hs_accounts.account_hv"; // when historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); @@ -91,7 +86,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { // then assertThat(rowsBefore) - .as("hs_accounts.profile_hv only contain no rows for a timestamp before test data creation") + .as("hs_accounts.account_hv only contain no rows for a timestamp before test data creation") .hasSize(0); // and when @@ -101,12 +96,12 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { // then assertThat(rowsAfter) - .as("hs_accounts.profile_hv should now contain the test-data rows for the current timestamp") + .as("hs_accounts.account_hv should now contain the test-data rows for the current timestamp") .hasSize(3); } @Test - void representativeShouldFindOwnAndRepresentedProfileByCurrentSubject() { + void representativeShouldFindOwnAndRepresentedAccountByCurrentSubject() { // given final var firstGmbHPerson = givenPerson("First GmbH"); givenRelation(REPRESENTATIVE) @@ -114,163 +109,76 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { .withHolder(drewPerson) .withContact("some test contact") .inDatabase(); - givenProfile() + givenAccount() .forSubject("first-gmbh") .forPerson(firstGmbHPerson) - .withEMailAddress("first-gmbh@example.com") .inDatabase(); // when - final var foundProfile = attempt( + final var foundAccount = attempt( em, () -> { context(USER_DREW_SUBJECT_NAME); - return profileRepository.findByCurrentSubject(); + return accountRepository.findByCurrentSubject(); }) .assertNotNull().returnedValue(); // then - assertThat(foundProfile).hasSize(2) - .map(HsProfileEntity::getEmailAddress) + assertThat(foundAccount).hasSize(2) + .map(e -> e.getSubject().getName()) .containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com"); } @Test - void globalAdminShouldFindOnlyOwnProfileByCurrentSubject() { + void globalAdminShouldFindOnlyOwnAccountByCurrentSubject() { // when - final var foundProfile = attempt( + final var foundAccount = attempt( em, () -> { context(SUPERUSER_FRAN_SUBJECT_NAME); - return profileRepository.findByCurrentSubject(); + return accountRepository.findByCurrentSubject(); }) .assertNotNull().returnedValue(); // then - assertThat(foundProfile).hasSize(1) - .map(HsProfileEntity::getEmailAddress) + assertThat(foundAccount).hasSize(1) + .map(e -> e.getSubject().getName()) .containsExactlyInAnyOrder("fran@example.com"); } @Test void shouldFindByUuidUsingTestData() { // when - final var foundEntityOptional = profileRepository.findByUuid(alexSubject.getUuid()); + final var foundEntityOptional = accountRepository.findByUuid(alexSubject.getUuid()); // then assertThat(foundEntityOptional).isPresent() - .map(HsProfileEntity::getEmailAddress).contains("alex@example.com"); + .map(e -> e.getSubject().getName()) + .contains("alex@example.com"); } @Test - void shouldSaveProfileWithExistingScope() { + void shouldSaveAccount() { // given - final var existingScope = scopeRealRepo.findByTypeAndQualifier("HSADMIN", "prod") - .orElseThrow(); - final var newProfile = HsProfileEntity.builder() + final var newAccount = HsAccountEntity.builder() .subject(testUserSubject) .person(testUserPerson) - .active(true) - .emailAddress("test-user@example.com") .globalUid(2011) .globalGid(2011) - .scopes(mutableSetOf(existingScope)) .build(); // when - toCleanup(profileRepository.save(newProfile)); + toCleanup(accountRepository.save(newAccount)); em.flush(); em.clear(); // then - final var foundEntityOptional = profileRepository.findByUuid(testUserSubject.getUuid()); + final var foundEntityOptional = accountRepository.findByUuid(testUserSubject.getUuid()); assertThat(foundEntityOptional).isPresent(); final var foundEntity = foundEntityOptional.get(); - assertThat(foundEntity.getEmailAddress()).isEqualTo("test-user@example.com"); - assertThat(foundEntity.isActive()).isTrue(); assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version assertThat(foundEntity.getGlobalUid()).isEqualTo(2011); - - assertThat(foundEntity.getScopes()).hasSize(1) - .map(HsProfileScopeRealEntity::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)"); } - @Test - void shouldNotSaveProfileWithNewScope() { - // given - final var newScope = HsProfileScopeRealEntity.builder() - .type("MATRIX") - .qualifier("forbidden") - .build(); - final var newProfile = HsProfileEntity.builder() - .subject(drewSubject) - .active(true) - .emailAddress("drew.new@example.com") - .globalUid(2001) - .globalGid(2001) - .scopes(mutableSetOf(newScope)) - .build(); - - // when - final var exception = catchThrowable(() -> { - profileRepository.save(newProfile); - em.flush(); - }); - - // then - assertThat(exception).isNotNull().hasCauseInstanceOf(TransientObjectException.class); - } - - @Test - void shouldSaveNewProfileWithoutScope() { - // given - final var newProfile = HsProfileEntity.builder() - .subject(testUserSubject) - .person(testUserPerson) - .active(true) - .emailAddress("test.user.new@example.com") - .globalUid(20002) - .globalGid(2002) - .build(); - - // when - profileRepository.save(newProfile); - em.flush(); - em.clear(); - - // then - final var foundEntityOptional = profileRepository.findByUuid(testUserSubject.getUuid()); - assertThat(foundEntityOptional).isPresent(); - final var foundEntity = foundEntityOptional.get(); - assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com"); - assertThat(foundEntity.isActive()).isTrue(); - assertThat(foundEntity.getGlobalUid()).isEqualTo(20002); - assertThat(foundEntity.getGlobalGid()).isEqualTo(2002); - assertThat(foundEntity.getScopes()).isEmpty(); - } - - @Test - void shouldUpdateExistingProfile() { - // given - final var entityToUpdate = profileRepository.findByUuid(alexSubject.getUuid()).orElseThrow(); - final var initialVersion = entityToUpdate.getVersion(); - - // when - entityToUpdate.setActive(false); - entityToUpdate.setEmailAddress("updated.user1@example.com"); - final var savedEntity = profileRepository.save(entityToUpdate); - em.flush(); - em.clear(); - - // then - assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion); - final var updatedEntityOptional = profileRepository.findByUuid(alexSubject.getUuid()); - assertThat(updatedEntityOptional).isPresent(); - final var updatedEntity = updatedEntityOptional.get(); - assertThat(updatedEntity.isActive()).isFalse(); - assertThat(updatedEntity.getEmailAddress()).isEqualTo("updated.user1@example.com"); - } - - private RealSubjectEntity fetchSubjectByName(final String name) { final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name"; final Query query = em.createQuery(jpql, RealSubjectEntity.class); @@ -315,8 +223,8 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { return new RelationBuilder(relationType); } - private ProfileBuilder givenProfile() { - return new ProfileBuilder(); + private AccountBuilder givenAccount() { + return new AccountBuilder(); } private class RelationBuilder { @@ -361,12 +269,11 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { } } - private class ProfileBuilder { + private class AccountBuilder { private RealSubjectEntity subject; private HsOfficePersonRealEntity person; - private String emailAddress; - public ProfileBuilder forSubject(String subjectName) { + public AccountBuilder forSubject(String subjectName) { // only the RbacSubject can be created val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder() .name(subjectName) @@ -376,36 +283,22 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { return this; } - public ProfileBuilder forPerson(HsOfficePersonRealEntity person) { + public AccountBuilder forPerson(HsOfficePersonRealEntity person) { this.person = person; return this; } - public ProfileBuilder withEMailAddress(String emailAddress) { - this.emailAddress = emailAddress; - final var profile = HsProfileEntity.builder() + public HsAccountEntity inDatabase() { + + final var account = HsAccountEntity.builder() .uuid(subject.getUuid()) .subject(subject) .person(em.find(HsOfficePersonRealEntity.class, person.getUuid())) - .emailAddress(emailAddress) - .active(true) .build(); - return this; - } - - public HsProfileEntity inDatabase() { - - final var profile = HsProfileEntity.builder() - .uuid(subject.getUuid()) - .subject(subject) - .person(em.find(HsOfficePersonRealEntity.class, person.getUuid())) - .emailAddress(emailAddress) - .active(true) - .build(); - em.persist(profile); - toCleanup(profile); + em.persist(account); + toCleanup(account); em.flush(); - return profile; + return account; } } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java deleted file mode 100644 index cea44dc2..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java +++ /dev/null @@ -1,488 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import lombok.val; -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.hs.accounts.HsProfileEntity.HsProfileEntityBuilder; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; -import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; -import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; -import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; -import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Tag; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.HashSet; -import java.util.Set; -import java.util.function.Consumer; - -import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer; -import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON; -import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON; -import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; - -@Tag("generalIntegrationTest") -@Transactional -@SpringBootTest( - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT -) -@ActiveProfiles("fake-jwt") -// too complex database interaction for just a RestTest, thus a fully integrated test -class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup { - - @LocalServerPort - Integer port; - - @Autowired - Context context; - - @Autowired - RbacSubjectRepository rbacSubjectRepo; - - @Autowired - HsOfficePersonRealRepository realPersonRepo; - - @Autowired - HsProfileScopeRealRepository scopeRepo; - - @Autowired - HsProfileRepository profileRepo; - - @Autowired - HsProfileScopeRbacRepository scopeRbacRepo; - - @Autowired - JpaAttempt jpaAttempt; - - @PersistenceContext - EntityManager em; - - @BeforeEach - void setUp() { - context.define("superuser-alex@hostsharing.net"); - } - - @Nested - class GetCurrentUser { - - @Test - void shouldFetchCurrentLoginUser() throws Exception { - // given - context.define("superuser-alex@hostsharing.net"); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("superuser-alex@hostsharing.net")) - .port(port) - .when() - .get("http://localhost/api/hs/accounts/current") - .then().log().all().assertThat() - .statusCode(200) - .contentType("application/json") - .body("subject.name", equalTo("superuser-alex@hostsharing.net")) - .body("globalAdmin", equalTo(true)); - // @formatter:on - } - } - - @Nested - class GetProfileByUuid { - - @Test - void shouldFilterInvalidScopesRegardingNonNaturalPerson() { - // given - val legalPerson = givenLegalPerson("selfregistered-user-drew@hostsharing.org"); - val profileEntity = givenNewProfile("selfregistered-user-drew@hostsharing.org", - "test-subject1", legalPerson, builder -> { - builder.scopes(new HashSet<>(scopeRepo.findAll())); - }); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer(profileEntity.getSubject().getName())) - .port(port) - .when() - .get("http://localhost/api/hs/accounts/profiles/" + profileEntity.getUuid()) - .then().log().all().assertThat() - .statusCode(200) - .contentType("application/json") - .body("$", lenientlyEquals(""" - { - "person": { - "personType": "LEGAL_PERSON", - "tradeName": "Test Company", - "salutation": null, - "title": null, - "givenName": null, - "familyName": null - }, - "nickname": "test-subject1", - "totpSecrets": null, - "phonePassword": null, - "emailAddress": null, - "smsNumber": null, - "active": false, - "globalUid": null, - "globalGid": null, - "scopes": [ - { - "uuid": "33333333-3333-3333-3333-333333333333", - "type": "SSH", - "qualifier": "external", - "onlyForNaturalPersons": false, - "publicAccess": true - }, - { - "uuid": "66666666-6666-6666-6666-666666666666", - "type": "MASTODON", - "qualifier": "external", - "onlyForNaturalPersons": false, - "publicAccess": true - }, - { - "uuid": "77777777-7777-7777-7777-777777777777", - "type": "BBB", - "qualifier": "external", - "onlyForNaturalPersons": false, - "publicAccess": true - } - ] - } - """)); - // @formatter:on - } - } - - @Nested - class PostNewProfile { - - @Test - void shouldRejectCreatingProfileForUnrepresentedPerson() { - // given - val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org"); - val publicScope = scopeRepo.findByTypeAndQualifier("SSH", "external").orElseThrow(); - assertThat(publicScope.isPublicAccess()).as("precondition failed").isTrue(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "person.uuid": "%s", - "nickname": "new-user", - "active": true, - "globalUid": 30001, - "globalGid": 40001, - "scopes": [ - { - "uuid" : "%s" - } - ] - } - """.formatted(testPerson.getUuid(), publicScope.getUuid())) - .port(port) - .when() - .post("http://localhost/api/hs/accounts/profiles") - .then().log().all().assertThat() - .statusCode(403) - .contentType("application/json") - .body("message", containsString("wird von der eingeloggten Person nicht repräsentiert")); - // @formatter:on - } - - @Test - void shouldRejectCreatingProfileWithPrivateScopeForNormalUser() { - // given - val drewPerson = realPersonRepo.findPersonByOptionalNameLike("Drew").getFirst(); - val privateInternalSshScope = scopeRepo.findByTypeAndQualifier("SSH", "internal") - .map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow(); - val privateInternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "internal") - .map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow(); - val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external") - .map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "person.uuid": "%s", - "nickname": "new-user", - "active": true, - "globalUid": 30001, - "globalGid": 40001, - "scopes": [ - { "uuid" : "%s" }, - { "uuid" : "%s" }, - { "uuid" : "%s" } - ] - } - """.formatted( - drewPerson.getUuid(), - publicExternalMatrixScope.getUuid(), - privateInternalSshScope.getUuid(), - privateInternalMatrixScope.getUuid())) - .port(port) - .when() - .post("http://localhost/api/hs/accounts/profiles") - .then().log().all().assertThat() - .statusCode(403) - .contentType("application/json") - .body("message", containsString("Zugriff auf Geltungsbereich verweigert: 'MATRIX:internal', 'SSH:internal'")); - // @formatter:on - } - - @Test - void shouldRejectCreatingProfileWithNaturalPersonRequirementForNonNaturalPerson() { - // given - val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst(); - val hsadminProdScopeOnlyForNaturalPersons = scopeRepo.findByTypeAndQualifier("HSADMIN", "prod") - .map(HsProfileControllerAcceptanceTest::asNaturalPersonScope).orElseThrow(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("superuser-alex@hostsharing.net")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "person.uuid": "%s", - "nickname": "new-user", - "active": true, - "globalUid": 30001, - "globalGid": 40001, - "scopes": [ - { "uuid" : "%s" } - ] - } - """.formatted( - firstGmbHPerson.getUuid(), - hsadminProdScopeOnlyForNaturalPersons.getUuid())) - .port(port) - .when() - .post("http://localhost/api/hs/accounts/profiles") - .then().log().all().assertThat() - .statusCode(400) - .contentType("application/json") - .body("message", containsString("Geltungsbereich verlangt eine natürliche Person: 'HSADMIN:prod'")); - // @formatter:on - } - } - - @Nested - class PatchProfile { - - @Test - void shouldRejectPatchingProfileWithPrivateScopeForNormalUser() { - // given - context.define("selfregistered-user-drew@hostsharing.org"); - val drewProfileUuid = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow() - .getSubject().getUuid(); - val privateInternalSshScope = scopeRepo.findByTypeAndQualifier("SSH", "internal") - .map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow(); - val privateInternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "internal") - .map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow(); - val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external") - .map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "scopes": [ - { "uuid" : "%s" }, - { "uuid" : "%s" }, - { "uuid" : "%s" } - ] - } - """.formatted( - privateInternalSshScope.getUuid(), - publicExternalMatrixScope.getUuid(), - privateInternalMatrixScope.getUuid())) - .port(port) - .when() - .patch("http://localhost/api/hs/accounts/profiles/" + drewProfileUuid) - .then().log().all().assertThat() - .statusCode(403) - .contentType("application/json") - .body("message", containsString("Zugriff auf Geltungsbereich verweigert: 'MATRIX:internal', 'SSH:internal'")); - // @formatter:on - } - - @Test - void shouldRejectPatchingProfileAndRemovingTheOwnHsadminProfile() { - // given - context.define("selfregistered-user-drew@hostsharing.org"); - val drewProfileUuid = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow() - .getSubject().getUuid(); - val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external") - .map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "scopes": [ - { "uuid" : "%s" } - ] - } - """.formatted(publicExternalMatrixScope.getUuid())) - .port(port) - .when() - .patch("http://localhost/api/hs/accounts/profiles/" + drewProfileUuid) - .then().log().all().assertThat() - .statusCode(400) - .contentType("application/json") - .body("message", containsString("die eigenen hsadmin-Profile dürfen nicht entfernt werden")); - // @formatter:on - } - - - @Test - void shouldRejectActivatingProfileForNormalUser() { - // given - context.define("selfregistered-user-drew@hostsharing.org"); - val drewProfile = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow(); - val inactiveProfileUuid = createNewInactiveProfile(drewProfile.getPerson()).getSubject().getUuid(); - - RestAssured // @formatter:off - .given() - .header("Authorization", bearer("selfregistered-user-drew@hostsharing.org")) - .header("Accept-Language", "de") - .contentType(ContentType.JSON) - .body(""" - { - "active": true - } - """) - .port(port) - .when() - .patch("http://localhost/api/hs/accounts/profiles/" + inactiveProfileUuid) - .then().log().all().assertThat() - .statusCode(403) - .contentType("application/json") - .body("message", containsString("Only global admins are allowed to activate an inactive profile")); - // @formatter:on - } - - } - - // Helper methods - - private HsProfileEntity createNewInactiveProfile(final HsOfficePersonRealEntity person) { - return jpaAttempt.transacted(() -> { - context.define("superuser-alex@hostsharing.net"); - // only RbacSubject entities can be created - val rbacSubjectEntity = rbacSubjectRepo.create(RbacSubjectEntity.builder() - .name("some-inactive-profile") - .build()); - // but we need the RealSubjectEntity to be attached to the profile entity - val realSubjectEntity = em.find(RealSubjectEntity.class, rbacSubjectEntity.getUuid()); - - val inactiveCopy = HsProfileEntity.builder() - .person(person) - .subject(realSubjectEntity) - .active(false).build(); - em.persist(inactiveCopy); - em.flush(); - return toCleanup(inactiveCopy); - }).assertSuccessful().returnedValue(); - } - - private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) { - return jpaAttempt.transacted(() -> { - context.define(executingSubjectName); - return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() - .personType(LEGAL_PERSON) - .tradeName("Test Company") - .build())); - }).assertSuccessful().returnedValue(); - } - - private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) { - return jpaAttempt.transacted(() -> { - context.define(executingSubjectName); - return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() - .personType(NATURAL_PERSON) - .familyName("Test") - .givenName("User") - .build())); - }).assertSuccessful().returnedValue(); - } - - private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) { - return jpaAttempt.transacted(() -> { - context.define(executingSubjectName); - return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder() - .personType(NATURAL_PERSON) - .familyName("Test") - .givenName("Person") - .build())); - }).returnedValue(); - } - - private static HsProfileScopeRealEntity asNaturalPersonScope(@NotNull HsProfileScopeRealEntity scope) { - assertThat(scope.isOnlyForNaturalPersons()).as("precondition failed").isTrue(); - return scope; - } - - private static HsProfileScopeRealEntity asPrivateScope(@NotNull HsProfileScopeRealEntity scope) { - assertThat(scope.isPublicAccess()).as("precondition failed").isFalse(); - return scope; - } - - private static HsProfileScopeRealEntity asPublicScope(@NotNull HsProfileScopeRealEntity scope) { - assertThat(scope.isPublicAccess()).as("precondition failed").isTrue(); - return scope; - } - - private HsProfileEntity givenNewProfile( - final String executingSubjectName, - final String newSubjectName, final HsOfficePersonRealEntity person, - final Consumer modifier - ) { - return jpaAttempt.transacted(() -> { - context.define(executingSubjectName); - - // only RbacSubject entities can be created - val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder() - .name(newSubjectName) - .build()); - - context.define(subject.getName()); - val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid()); - val profileBuilder = HsProfileEntity.builder() - .person(attachedPerson) - .subject(em.find(RealSubjectEntity.class, subject.getUuid())) - .scopes(Set.of()); - modifier.accept(profileBuilder); - return toCleanup(profileRepo.save(profileBuilder.build())); - }).assertSuccessful().returnedValue(); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcherUnitTest.java deleted file mode 100644 index b3e2331b..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcherUnitTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import lombok.val; -import net.hostsharing.hsadminng.config.MessageTranslator; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource; -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.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; - -@TestInstance(PER_CLASS) -@ExtendWith(MockitoExtension.class) -class HsProfileEntityPatcherUnitTest extends PatchUnitTestBase< - ProfilePatchResource, - HsProfileEntity - > { - - private static final UUID INITIAL_PROFILE_UUID = UUID.randomUUID(); - - private static final Boolean INITIAL_ACTIVE = true; - private static final String INITIAL_EMAIL_ADDRESS = "initial@example.com"; - private static final List INITIAL_TOTP_SECRETS = List.of("initial_2fa"); - private static final String INITIAL_SMS_NUMBER = "initial_sms"; - private static final String INITIAL_PHONE_PASSWORD = "initial_phone_pw"; - - private static final Boolean PATCHED_ACTIVE = false; - private static final String PATCHED_EMAIL_ADDRESS = "patched@example.com"; - private static final List PATCHED_TOTP_SECRETS = List.of("patched_2fa"); - private static final String PATCHED_SMS_NUMBER = "patched_sms"; - private static final String PATCHED_PHONE_PASSWORD = "patched_phone_pw"; - - // Scopes - private static final UUID SCOPE_UUID_1 = UUID.randomUUID(); - private static final UUID SCOPE_UUID_2 = UUID.randomUUID(); - private static final UUID SCOPE_UUID_3 = UUID.randomUUID(); - - private final HsProfileScopeRealEntity initialScopeEntity1 = HsProfileScopeRealEntity.builder() - .uuid(SCOPE_UUID_1) - .type("HSADMIN") - .qualifier("prod") - .build(); - private final HsProfileScopeRealEntity initialScopeEntity2 = HsProfileScopeRealEntity.builder() - .uuid(SCOPE_UUID_2) - .type("SSH") - .qualifier("dev") - .build(); - - // This is what em.find should return for SCOPE_UUID_3 - private final HsProfileScopeRealEntity newScopeEntity3 = HsProfileScopeRealEntity.builder() - .uuid(SCOPE_UUID_3) - .type("HSADMIN") - .qualifier("test") - .build(); - - private final Set initialScopeEntities = Set.of(initialScopeEntity1, initialScopeEntity2); - private List patchedScopeResources; - private final Set expectedPatchedScopeEntities = Set.of(initialScopeEntity2, - newScopeEntity3); - - @Mock - private EntityManager em; - - @BeforeEach - void initMocks() { - // Mock em.find for scopes that are part of the patch and need to be fetched - lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_1))).thenReturn(initialScopeEntity1); - lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_2))).thenReturn(initialScopeEntity2); - lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_3))).thenReturn(newScopeEntity3); - - val patchScopeResource2 = new ScopeResource(); - patchScopeResource2.setUuid(SCOPE_UUID_2); - patchScopeResource2.setType("SSH"); - patchScopeResource2.setQualifier("dev"); - - val patchScopeResource3 = new ScopeResource(); - patchScopeResource3.setUuid(SCOPE_UUID_3); - patchScopeResource3.setType("HSADMIN"); - patchScopeResource3.setQualifier("test"); - - patchedScopeResources = List.of(patchScopeResource2, patchScopeResource3); - } - - @Override - protected HsProfileEntity newInitialEntity() { - final var entity = new HsProfileEntity(); - entity.setUuid(INITIAL_PROFILE_UUID); - entity.setActive(INITIAL_ACTIVE); - entity.setEmailAddress(INITIAL_EMAIL_ADDRESS); - entity.setTotpSecrets(INITIAL_TOTP_SECRETS); - entity.setSmsNumber(INITIAL_SMS_NUMBER); - entity.setPhonePassword(INITIAL_PHONE_PASSWORD); - // Ensure scopes is a mutable set for the patcher - entity.setScopes(new HashSet<>(initialScopeEntities)); - return entity; - } - - @Override - protected ProfilePatchResource newPatchResource() { - return new ProfilePatchResource(); - } - - @Override - protected HsProfileEntityPatcher createPatcher(final HsProfileEntity entity) { - final var scopeMapper = new ScopeResourceToEntityMapper(em, mock(MessageTranslator.class)); - return new HsProfileEntityPatcher(scopeMapper, entity); - } - - @Override - protected Stream propertyTestDescriptors() { - return Stream.of( - new SimpleProperty<>( - "active", - ProfilePatchResource::setActive, - PATCHED_ACTIVE, - HsProfileEntity::setActive, - PATCHED_ACTIVE) - .notNullable(), - new JsonNullableProperty<>( - "emailAddress", - ProfilePatchResource::setEmailAddress, - PATCHED_EMAIL_ADDRESS, - HsProfileEntity::setEmailAddress, - PATCHED_EMAIL_ADDRESS), - new SimpleProperty<>( - "totpSecret", - ProfilePatchResource::setTotpSecrets, - PATCHED_TOTP_SECRETS, - HsProfileEntity::setTotpSecrets, - PATCHED_TOTP_SECRETS) - .notNullable(), - new JsonNullableProperty<>( - "smsNumber", - ProfilePatchResource::setSmsNumber, - PATCHED_SMS_NUMBER, - HsProfileEntity::setSmsNumber, - PATCHED_SMS_NUMBER), - new JsonNullableProperty<>( - "phonePassword", - ProfilePatchResource::setPhonePassword, - PATCHED_PHONE_PASSWORD, - HsProfileEntity::setPhonePassword, - PATCHED_PHONE_PASSWORD), - new SimpleProperty<>( - "scopes", - ProfilePatchResource::setScopes, - patchedScopeResources, - HsProfileEntity::setScopes, - expectedPatchedScopeEntities) - .notNullable() - ); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java deleted file mode 100644 index 7732b3a2..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import lombok.val; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; -import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; -import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; -import org.junit.jupiter.api.Test; - -import jakarta.validation.ValidationException; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -class HsProfileEntityUnitTest { - - static final HsProfileEntity GIVEN_PROFILE_ENTITY = HsProfileEntity.builder() - .uuid(UUID.fromString("11111111-1111-1111-1111-111111111111")) - .subject( - RealSubjectEntity.builder().uuid(UUID.randomUUID()).name("testSubject").build()) - .person( - HsOfficePersonRealEntity.builder() - .personType(HsOfficePersonType.NATURAL_PERSON) - .familyName("Miller") - .givenName("John") - .build() - ) - .emailAddress("john.miller@example.com") - .smsNumber("+49 1234567890") - .globalUid(10001) - .globalUid(20002) - .phonePassword("hello world") - .totpSecrets(List.of("secret1", "secret2")) - .active(true) - .build(); - - @Test - void toShortStringContainsJustTypeAndQualifier() { - assertThat(GIVEN_PROFILE_ENTITY.toShortString()).isEqualTo("true:john.miller@example.com:20002"); - } - - @Test - void toStringContainsAllPropertiesExceptUuidAndPasswordHash() { - assertThat(GIVEN_PROFILE_ENTITY.toString()).isEqualTo("profile(true, john.miller@example.com, [secret1, secret2], hello world, +49 1234567890)"); - } - - @Test - void setPasswordSetsPasswordHash() { - val profile = HsProfileEntity.builder().build(); - profile.setPassword("my password"); - assertThat(profile.getPasswordHash()).startsWith("{SSHA}"); - } - - @Test - void acceptsValidSshaPasswordHash() { - val givenSshaHash = "{SSHA}SNBnIh5QomfgrvDLDwBR+JOcc8Y17H+4"; - val profile = HsProfileEntity.builder().build(); - profile.setPasswordHash(givenSshaHash); - assertThat(profile.getPasswordHash()).isEqualTo(givenSshaHash); - } - - @Test - void acceptsValidArgon2PasswordHash() { - val givenArgon2Hash = "{ARGON2}$argon2id$v=19$m=65536,t=3,p=1$pEabRksh7EJQV+OwPR5n7Q$83qQtZe2J8+fteWm7g/uvXksfhJKGsipZFsuAaJtBjs"; - val profile = HsProfileEntity.builder().build(); - profile.setPasswordHash(givenArgon2Hash); - assertThat(profile.getPasswordHash()).isEqualTo(givenArgon2Hash); - } - - @Test - void rejectInvalidPasswordHash() { - val profile = HsProfileEntity.builder().build(); - val throwable = assertThrows( - ValidationException.class, - () -> profile.setPasswordHash("{whatever} but not a valid hash")); - assertThat(throwable.getMessage()).isEqualTo("passwordHash must be SSHA or ARGON2 hash valid for LDAP"); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeControllerRestTest.java deleted file mode 100644 index 42446788..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeControllerRestTest.java +++ /dev/null @@ -1,210 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import static java.util.Collections.emptyList; -import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer; -import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; -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.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import net.hostsharing.hsadminng.config.WebSecurityConfigForWebMvcTests; -import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; -import net.hostsharing.hsadminng.config.MessageTranslator; -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.mapper.StrictMapper; -import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -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.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -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; - -@WebMvcTest(HsProfileScopeController.class) -@Import({ StrictMapper.class, - MessageTranslator.class, - JsonObjectMapperConfiguration.class, - WebSecurityConfigForWebMvcTests.class }) -@ActiveProfiles({"fake-jwt", "test"}) -class HsProfileScopeControllerRestTest { - - @Autowired - MockMvc mockMvc; - - @MockitoBean - Context contextMock; - - @Autowired - @SuppressWarnings("unused") // not used in test but in controller class - StrictMapper mapper; - - @MockitoBean - EntityManagerWrapper em; - - @MockitoBean - EntityManagerFactory emf; - - @MockitoBean - HsProfileScopeRbacRepository scopeRbacRepo; - - @TestConfiguration - public static class TestConfig { - @Bean - public EntityManager entityManager() { - return mock(EntityManager.class); - } - } - - @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); - } - - @Test - void getListOfScopesReturnsOkWithEmptyList() throws Exception { - - // given - givenNoScopesInTheRepository(); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/hs/accounts/scopes") - .header("Authorization", bearer("superuser-alex@hostsharing.net")) - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); - } - - @Test - void getListOfScopesReturnsAllScopesForGlobalAdmin() throws Exception { - - // given - givenSomeScopesInTheRepository(); - when(contextMock.isGlobalAdmin()).thenReturn(true); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/hs/accounts/scopes") - .header("Authorization", bearer("Bearer superuser-alex@hostsharing.net")) - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath( - "$", lenientlyEquals(""" - [ - { - "type": "HSADMIN", - "qualifier": "prod", - "onlyForNaturalPersons": true, - "publicAccess": true - }, - { - "type": "SSH", - "qualifier": "public", - "onlyForNaturalPersons": false, - "publicAccess": true - }, - { - "type": "SSH", - "qualifier": "internal", - "onlyForNaturalPersons": false, - "publicAccess": false - } - ] - """ - ))); - } - - @Test - void getListOfScopesReturnsOnlyPublicScopesForNormalUser() throws Exception { - - // given - givenSomeScopesInTheRepository(); - when(contextMock.isGlobalAdmin()).thenReturn(false); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/hs/accounts/scopes") - .header("Authorization", bearer("drew@hostsharing.org")) - .accept(MediaType.APPLICATION_JSON)) - .andDo(print()) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath( - "$", lenientlyEquals(""" - [ - { - "type": "HSADMIN", - "qualifier": "prod", - "onlyForNaturalPersons": true, - "publicAccess": true - }, - { - "type": "SSH", - "qualifier": "public", - "onlyForNaturalPersons": false, - "publicAccess": true - } - ] - """ - ))); - } - - private void givenNoScopesInTheRepository() { - when(scopeRbacRepo.findAll()).thenReturn(emptyList()); - } - - private void givenSomeScopesInTheRepository() { - when(scopeRbacRepo.findAll()).thenReturn(List.of( - HsProfileScopeRbacEntity.builder() - .uuid(UUID.randomUUID()) - .type("HSADMIN") - .qualifier("prod") - .publicAccess(true) - .onlyForNaturalPersons(true) - .build(), - HsProfileScopeRbacEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("public") - .publicAccess(true) - .onlyForNaturalPersons(false) - .build(), - HsProfileScopeRbacEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("internal") - .publicAccess(false) - .onlyForNaturalPersons(false) - .build() - )); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntityUnitTest.java deleted file mode 100644 index e366bd8f..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacEntityUnitTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class HsProfileScopeRbacEntityUnitTest { - - @Test - void toShortStringContainsJustTypeAndQualifier() { - final var entity = HsProfileScopeRbacEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .publicAccess(true) - .build(); - assertEquals("SSH:prod", entity.toShortString()); - } - - @Test - void toStringContainsAllNonNullFields() { - final var entity = HsProfileScopeRbacEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .publicAccess(true) - .build(); - assertEquals("scope(SSH:prod:PUBLIC)", entity.toString()); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepositoryIntegrationTest.java deleted file mode 100644 index e2364c16..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRbacRepositoryIntegrationTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; -import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.postgresql.util.PSQLException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.transaction.annotation.Transactional; - -import jakarta.persistence.PersistenceException; -import jakarta.servlet.http.HttpServletRequest; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; - -@DataJpaTest -@ActiveProfiles("test") -@Tag("generalIntegrationTest") -@Import({ Context.class, JpaAttempt.class }) -@Transactional -class HsProfileScopeRbacRepositoryIntegrationTest extends ContextBasedTest { - - // existing UUIDs from test data (Liquibase changeset 310-login-profile-test-data.sql) - private static final UUID TEST_SCOPE_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); - private static final UUID TEST_SCOPE_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333"); - - private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; - private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org"; - - @MockitoBean - HttpServletRequest request; - - @Autowired - private HsProfileScopeRbacRepository scopesRepository; - - @Test - void shouldFindAllByNormalUserUsingTestData() { - context(TEST_USER_SUBJECT_NAME); - - // when - final var allScopes = scopesRepository.findAll(); - - // then - assertThat(allScopes) - .isNotNull() - .hasSizeGreaterThanOrEqualTo(1) // Expect at least the 1 public context from assumed test data - .extracting(HsProfileScope::getUuid) - .contains(TEST_SCOPE_HSADMIN_PROD_UUID); - } - - @Test - void shouldFindAllByAdminUserUsingTestData() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // when - final var allScopes = scopesRepository.findAll(); - - // then - assertThat(allScopes) - .isNotNull() - .hasSizeGreaterThanOrEqualTo(3); // Expect at least the 1 public scope from assumed test data - } - - @Test - void shouldFindByUuidUsingTestData() { - context(TEST_USER_SUBJECT_NAME); - - // when - final var foundEntityOptional = scopesRepository.findByUuid(TEST_SCOPE_HSADMIN_PROD_UUID); - - // then - assertThat(foundEntityOptional).isPresent(); - assertThat(foundEntityOptional).map(Object::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)"); - } - - @Test - void shouldFindByTypeAndQualifierUsingTestData() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // when - final var foundEntityOptional = scopesRepository.findByTypeAndQualifier("SSH", "internal"); - - // then - assertThat(foundEntityOptional).isPresent(); - assertThat(foundEntityOptional).map(Object::toString).contains("scope(SSH:internal:NP-ONLY:INTERNAL)"); - } - - @Test - void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // given - final var nonExistentQualifier = "non-existent-qualifier"; - - // when - final var foundEntityOptional = scopesRepository.findByTypeAndQualifier( - "HSADMIN", nonExistentQualifier); - - // then - assertThat(foundEntityOptional).isNotPresent(); - } - - @Test - void shouldSaveNewScope() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // given - final var newQualifier = "test@example.social"; - final var newType = "MASTODON"; - final var newScope = HsProfileScopeRbacEntity.builder() - .type(newType) - .qualifier(newQualifier) - .build(); - - // when - final var savedEntity = scopesRepository.save(newScope); - em.flush(); - em.clear(); - - // then - assertThat(savedEntity).isNotNull(); - final var generatedUuid = savedEntity.getUuid(); - assertThat(generatedUuid).isNotNull(); // Verify UUID was generated - - // Fetch again using the generated UUID to confirm persistence - context(SUPERUSER_ALEX_SUBJECT_NAME); // Re-set context if needed after clear - final var foundEntityOptional = scopesRepository.findByUuid(generatedUuid); - assertThat(foundEntityOptional).isPresent(); - final var foundEntity = foundEntityOptional.get(); - assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid); - assertThat(foundEntity.getType()).isEqualTo(newType); - assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier); - } - - @Test - void shouldPreventUpdateOfExistingScope() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // given an existing entity from test data - final var entityToUpdateOptional = scopesRepository.findByUuid(TEST_SCOPE_MATRIX_INTERNAL_UUID); - assertThat(entityToUpdateOptional) - .withFailMessage("Could not find existing scope with UUID %s. Ensure test data exists.", - TEST_SCOPE_MATRIX_INTERNAL_UUID) - .isPresent(); - final var entityToUpdate = entityToUpdateOptional.get(); - - // when - entityToUpdate.setQualifier("updated"); - final var exception = catchThrowable( () -> { - scopesRepository.save(entityToUpdate); - em.flush(); - }); - - // then - assertThat(exception) - .isInstanceOf(PersistenceException.class) - .hasCauseInstanceOf(PSQLException.class) - .hasMessageContaining("ERROR: Updates to hs_accounts.scope are not allowed."); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntityUnitTest.java deleted file mode 100644 index 40971ff3..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealEntityUnitTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class HsProfileScopeRealEntityUnitTest { - - @Test - void toShortStringContainsJustTypeAndQualifier() { - final var entity = HsProfileScopeRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .publicAccess(true) - .build(); - assertEquals("SSH:prod", entity.toShortString()); - } - - @Test - void toStringContainsAllNonNullFields() { - final var entity = HsProfileScopeRealEntity.builder() - .uuid(UUID.randomUUID()) - .type("SSH") - .qualifier("prod") - .publicAccess(true) - .build(); - assertEquals("scope(SSH:prod:PUBLIC)", entity.toString()); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepositoryIntegrationTest.java deleted file mode 100644 index 4eefe012..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileScopeRealRepositoryIntegrationTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts; - -import net.hostsharing.hsadminng.rbac.context.Context; -import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; -import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.postgresql.util.PSQLException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import jakarta.persistence.PersistenceException; -import jakarta.servlet.http.HttpServletRequest; -import java.sql.Timestamp; -import java.time.ZonedDateTime; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; - -@DataJpaTest -@ActiveProfiles("test") -@Tag("generalIntegrationTest") -@Import({ Context.class, JpaAttempt.class }) -class HsProfileScopeRealRepositoryIntegrationTest extends ContextBasedTest { - - // existing UUIDs from test data (Liquibase changeset 310-login-profile-test-data.sql) - private static final UUID TEST_SCOPE_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); - private static final UUID TEST_SCOPE_SSH_INTERNAL_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222"); - private static final UUID TEST_SCOPE_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333"); - - private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; - private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org"; - - @MockitoBean - HttpServletRequest request; - - @Autowired - private HsProfileScopeRealRepository scopeRepository; - - @Test - public void historizationIsAvailable() { - // given - final String nativeQuerySql = "select * from hs_accounts.scope_hv"; - - // when - historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); - final var query = em.createNativeQuery(nativeQuerySql); - final var rowsBefore = query.getResultList(); - - // then - assertThat(rowsBefore) - .as("hs_accounts.scope_hv only contain no rows for a timestamp before test data creation") - .hasSize(0); - - // and when - historicalContext(Timestamp.from(ZonedDateTime.now().toInstant())); - em.createNativeQuery(nativeQuerySql, Integer.class); - final var rowsAfter = query.getResultList(); - - // then - assertThat(rowsAfter) - .as("hs_accounts.scope_hv should now contain the test-data rows for the current timestamp") - .hasSize(7); - } - - @Test - void shouldFindAllUsingTestData() { - context(TEST_USER_SUBJECT_NAME); - - // when - final var allScopes = scopeRepository.findAll(); - - // then - assertThat(allScopes) - .isNotNull() - .hasSizeGreaterThanOrEqualTo(3) // Expect at least the 3 from assumed test data - .extracting(HsProfileScope::getUuid) - .contains(TEST_SCOPE_HSADMIN_PROD_UUID, TEST_SCOPE_SSH_INTERNAL_UUID, TEST_SCOPE_MATRIX_INTERNAL_UUID); - } - - @Test - void shouldFindByUuidUsingTestData() { - context(TEST_USER_SUBJECT_NAME); - - // when - final var foundEntityOptional = scopeRepository.findByUuid(TEST_SCOPE_HSADMIN_PROD_UUID); - - // then - assertThat(foundEntityOptional).isPresent(); - assertThat(foundEntityOptional).map(Object::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)"); - } - - @Test - void shouldFindByTypeAndQualifierUsingTestData() { - context(TEST_USER_SUBJECT_NAME); - - // when - final var foundEntityOptional = scopeRepository.findByTypeAndQualifier("SSH", "internal"); - - // then - assertThat(foundEntityOptional).isPresent(); - assertThat(foundEntityOptional).map(Object::toString).contains("scope(SSH:internal:NP-ONLY:INTERNAL)"); - } - - @Test - void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() { - context(TEST_USER_SUBJECT_NAME); - - // given - final var nonExistentQualifier = "non-existent-qualifier"; - - // when - final var foundEntityOptional = scopeRepository.findByTypeAndQualifier( - "HSADMIN", nonExistentQualifier); - - // then - assertThat(foundEntityOptional).isNotPresent(); - } - - @Test - void shouldSaveNewScope() { - context(SUPERUSER_ALEX_SUBJECT_NAME); - - // given - final var newQualifier = "test@example.social"; - final var newType = "MASTODON"; - final var newScope = HsProfileScopeRealEntity.builder() - .type(newType) - .qualifier(newQualifier) - .build(); - - // when - final var savedEntity = scopeRepository.save(newScope); - em.flush(); - em.clear(); - - // then - assertThat(savedEntity).isNotNull(); - final var generatedUuid = savedEntity.getUuid(); - assertThat(generatedUuid).isNotNull(); // Verify UUID was generated - - // Fetch again using the generated UUID to confirm persistence - context(TEST_USER_SUBJECT_NAME); // Re-set context if needed after clear - final var foundEntityOptional = scopeRepository.findByUuid(generatedUuid); - assertThat(foundEntityOptional).isPresent(); - final var foundEntity = foundEntityOptional.get(); - assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid); - assertThat(foundEntity.getType()).isEqualTo(newType); - assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier); - } - - @Test - void shouldPreventUpdateOfExistingScope() { - context(TEST_USER_SUBJECT_NAME); - - // given an existing entity from test data - final var entityToUpdateOptional = scopeRepository.findByUuid(TEST_SCOPE_MATRIX_INTERNAL_UUID); - assertThat(entityToUpdateOptional) - .withFailMessage("Could not find existing Scope with UUID %s. Ensure test data exists.", - TEST_SCOPE_MATRIX_INTERNAL_UUID) - .isPresent(); - final var entityToUpdate = entityToUpdateOptional.get(); - - // when - entityToUpdate.setQualifier("updated"); - final var exception = catchThrowable( () -> { - scopeRepository.save(entityToUpdate); - em.flush(); - }); - - // then - assertThat(exception) - .isInstanceOf(PersistenceException.class) - .hasCauseInstanceOf(PSQLException.class) - .hasMessageContaining("ERROR: Updates to hs_accounts.scope are not allowed."); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnMemberships.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnMemberships.java new file mode 100644 index 00000000..b5a3806e --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnMemberships.java @@ -0,0 +1,53 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; + +import static io.restassured.http.ContentType.JSON; +import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; +import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; +import static org.springframework.http.HttpStatus.OK; + +public class AccountCanViewTheirOwnMemberships extends BaseAccountUseCase { + + public AccountCanViewTheirOwnMemberships(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) { + super(scenarioTest, asLoginUser); + } + + @Override + protected HttpResponse run() { + obtain( + "personUuid", + () -> httpGet( asLoginUser, "/api/hs/accounts/accounts") + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"), + "Fetch the account for the current subject to resolve the related person." + ); + + withTitle("Resolve partner trade name", () -> + httpGet( asLoginUser, + "/api/hs/office/relations?relationType=REPRESENTATIVE&personUuid=%{personUuid}") + .expecting(OK).expecting(JSON).expectArrayElements(1) + .extractValue("[0].anchor.tradeName", "partnerTradeName") + ); + + withTitle("Resolve partner UUID", () -> + httpGet( asLoginUser, + "/api/hs/office/partners?name=" + uriEncoded("%{partnerTradeName}") + ) + .expecting(OK).expecting(JSON).expectArrayElements(1) + .extractUuidAlias("[0].uuid", "partnerUuid") + ); + + return withTitle("View their memberships", () -> + httpGet( asLoginUser, "/api/hs/office/memberships?partnerUuid=%{partnerUuid}") + .expecting(OK).expecting(JSON) + ); + } + + @Override + protected void verify(final HttpResponse response) { + final var expectedMembershipsJson = ScenarioTest.resolve("%{expectedMembershipsJson}", DROP_COMMENTS); + lenientlyEquals(expectedMembershipsJson).matches(response.getBody()); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnPerson.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnPerson.java new file mode 100644 index 00000000..0d34657a --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnPerson.java @@ -0,0 +1,42 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; + +import static io.restassured.http.ContentType.JSON; +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject; +import static org.springframework.http.HttpStatus.OK; + +public class AccountCanViewTheirOwnPerson extends BaseAccountUseCase { + + public AccountCanViewTheirOwnPerson(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) { + super(scenarioTest, asLoginUser); + } + + @Override + protected HttpResponse run() { + obtain( + "personUuid", + () -> httpGet( asSubject("%{subjectName}"), + "/api/hs/accounts/accounts" + ) + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"), + "Fetch the account for the current subject to resolve the related person." + ); + + return withTitle("View Own Person", () -> + httpGet( asSubject("%{subjectName}"), + "/api/hs/office/persons/%{personUuid}" + ) + .expecting(OK).expecting(JSON) + ); + } + + @Override + protected void verify(final HttpResponse response) { + path("uuid").contains("%{personUuid}").accept(response); + path("givenName").contains("%{personGivenName}").accept(response); + path("familyName").contains("%{personFamilyName}").accept(response); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnRelations.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnRelations.java new file mode 100644 index 00000000..6c970bc2 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountCanViewTheirOwnRelations.java @@ -0,0 +1,40 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import lombok.val; +import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; + +import static io.restassured.http.ContentType.JSON; +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject; +import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; +import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; +import static org.springframework.http.HttpStatus.OK; + +public class AccountCanViewTheirOwnRelations extends BaseAccountUseCase { + + public AccountCanViewTheirOwnRelations(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) { + super(scenarioTest, asLoginUser); + } + + @Override + protected HttpResponse run() { + obtain( + "personUuid", + () -> httpGet( asSubject("%{subjectName}"), "/api/hs/accounts/accounts") + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"), + "Fetch the account for the current subject to resolve the related person." + ); + + return withTitle("View their relations", () -> + httpGet(asSubject("%{subjectName}"), "/api/hs/office/relations?personUuid%{personUuid}") + .expecting(OK).expecting(JSON) + ); + } + + @Override + protected void verify(final HttpResponse response) { + val expectedRelationsJson = ScenarioTest.resolve("%{expectedRelationsJson}", DROP_COMMENTS); + lenientlyEquals(expectedRelationsJson).matches(response.getBody()); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountScenarioTests.java new file mode 100644 index 00000000..23e63aca --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/AccountScenarioTests.java @@ -0,0 +1,232 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.scenarios.Produces; +import net.hostsharing.hsadminng.hs.scenarios.Requires; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import org.junit.jupiter.api.BeforeEach; +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.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.http.HttpStatus; + +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject; +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent; + +class AccountScenarioTests extends ScenarioTest { + + @SneakyThrows + @BeforeEach + protected void beforeScenario(final TestInfo testInfo) { + super.beforeScenario(testInfo); + } + + @Nested + @Order(90) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class RbacContextScenarios { + + @Test + @Order(9010) + @Produces("RBAC Context") + void shouldFetchRbacContext() { + new FetchRbacContext(scenarioTest) + .given("subjectName", "superuser-fran@hostsharing.net") + .given("assumedRoles", "rbactest.package#xxx00:ADMIN;rbactest.package#yyy00:ADMIN") + .given("expectedToBeGlobalAdmin", true) + .thenExpect(HttpStatus.OK) + .keep(); + } + } + + @Nested + @Order(91) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class CurrentLoginUserScenarios { + + @Test + @Order(9110) + @Produces("Current Login User") + void shouldFetchCurrentLoginUser() { + new CurrentLoginUser(scenarioTest) + .given("subjectName", "superuser-fran@hostsharing.net") + .given("personGivenName", "Fran") + .given("expectedToBeGlobalAdmin", true) + .thenExpect(HttpStatus.OK) + .keep(); + } + } + + @Nested + @Order(92) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class ScenariosForExistingPersons { + + @Test + @Order(9210) + @Produces( + explicitly = "Account: peter-smith", + implicitly = { "Person: Peter Smith" }) + void shouldCreateInitialAccountForExistingNaturalPerson() { + new CreateAccountForExistingPerson(scenarioTest, asGlobalAgent()) + // to find a specific existing person + .given("personFamilyName", "Smith") + .given("personGivenName", "Peter") + .given("personGivenType", "NATURAL_PERSON") + // a login name, to be stored in the new RBAC subject + .given("subjectName", "xyz-peter.smith") + // initial account + .given("globalUid", 21011) + .given("globalGid", 21011) + .thenExpect(HttpStatus.OK) + .keep(); + } + + @Test + @Order(9211) + @Requires("Account: peter-smith") + void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewThatPerson() { + new AccountCanViewTheirOwnPerson(scenarioTest, asSubject("xyz-peter.smith")) + // to find a specific existing person + .given("subjectName", "xyz-peter.smith") + // some expected person data + .expected("personFamilyName", "Smith") + .expected("personGivenName", "Peter") + .thenExpect(HttpStatus.OK); + } + + @Test + @Order(9212) + @Requires("Account: peter-smith") + void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewExistingRelations() { + new AccountCanViewTheirOwnRelations(scenarioTest, asSubject("xyz-peter.smith")) + // to find a specific existing person + .given("subjectName", "xyz-peter.smith") + // some expected person data ... which might change if test-data changes + .expected("expectedRelationsJson", """ + [ + { + "type": "PARTNER", + "mark": null, + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." }, + "contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } } + }, + { + "type": "DEBITOR", + "mark": null, + "anchor": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." }, + "holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." }, + "contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } } + }, + { + "type": "REPRESENTATIVE", + "mark": null, + "anchor": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." }, + "holder": { "givenName": "Peter", "familyName": "Smith" }, + "contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } } + }, + { + "type": "PARTNER", + "mark": null, + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "givenName": "Peter", "familyName": "Smith" }, + "contact": { "emailAddresses": { "main": "contact-admin@sixthcontact.example.com" } } + }, + { + "type": "DEBITOR", + "mark": null, + "anchor": { "givenName": "Peter", "familyName": "Smith" }, + "holder": { "givenName": "Peter", "familyName": "Smith" }, + "contact": { "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } } + }, + { + "type": "SUBSCRIBER", + "mark": "members-announce", + "anchor": { "tradeName": "Third OHG" }, + "holder": { "givenName": "Peter", "familyName": "Smith" }, + "contact": { "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } } + } + ] + """) + .thenExpect(HttpStatus.OK); + } + + @Test + @Order(9213) + @Requires("Account: peter-smith") + void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewExistingMemberships() { + new AccountCanViewTheirOwnMemberships(scenarioTest, asSubject("xyz-peter.smith")) + // to find a specific existing person + .given("subjectName", "xyz-peter.smith") + // some expected membership data ... which might change if test-data changes + .expected("expectedMembershipsJson", """ + [ + { + "partner": { + "partnerNumber": "P-10002", + "partnerRel": { + "type": "PARTNER", + "anchor": { "tradeName": "Hostsharing eG" }, + "holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." }, + "contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } } + }, + "details": { + "registrationOffice": "Hamburg", + "registrationNumber": "RegNo123456789" + } + }, + "memberNumber": "M-1000202", + "memberNumberSuffix": "02", + "validFrom": "2022-10-01", + "validTo": "2025-12-31", + "status": "CANCELLED", + "membershipFeeBillable": true + } + ] + """) + .thenExpect(HttpStatus.OK); + } + } + + @Nested + @Order(93) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class ScenariosForImplicitlyCreatedPersons { + + @Test + @Order(9310) + @Produces( + explicitly = "Account: peter-newman", + implicitly = { "Person: Peter Newman" }) + void shouldCreateInitialAccountForNewNaturalPerson() { + new CreateAccountForNewPerson(scenarioTest, asGlobalAgent()) + // to find a specific existing person + .given("personFamilyName", "Newman") + .given("personGivenName", "Peter") + // a login name, to be stored in the new RBAC subject + .given("subjectName", "xyz-peter.newman") + // initial account + .given("globalUid", 21012) + .given("globalGid", 21012) + .thenExpect(HttpStatus.OK) + .keep(); + } + + @Test + @Order(9311) + @Requires("Account: peter-newman") + void newlyCreatedAccountForNewNaturalPersonShouldBeAbleToViewThatPerson() { + new AccountCanViewTheirOwnPerson(scenarioTest, asSubject("xyz-peter.newman")) + // to find a specific existing person + .given("subjectName", "xyz-peter.newman") + // some expected person data + .expected("personFamilyName", "Newman") + .expected("personGivenName", "Peter") + .thenExpect(HttpStatus.OK); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseAccountUseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseAccountUseCase.java new file mode 100644 index 00000000..68870cf5 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseAccountUseCase.java @@ -0,0 +1,15 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.scenarios.UseCase; + +public abstract class BaseAccountUseCase> extends UseCase { + + protected final FakeLoginUser asLoginUser; + + public BaseAccountUseCase(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { + super(testSuite); + this.asLoginUser = asLoginUser; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseProfileUseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseProfileUseCase.java deleted file mode 100644 index c9100490..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/BaseProfileUseCase.java +++ /dev/null @@ -1,43 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts.scenarios; - -import lombok.SneakyThrows; -import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource; -import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; -import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; -import net.hostsharing.hsadminng.hs.scenarios.UseCase; -import org.apache.commons.lang3.tuple.Pair; - -import java.util.Arrays; - -import static io.restassured.http.ContentType.JSON; -import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent; -import static org.springframework.http.HttpStatus.OK; - -public abstract class BaseProfileUseCase> extends UseCase { - - protected final FakeLoginUser asLoginUser; - - public BaseProfileUseCase(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { - super(testSuite); - this.asLoginUser = asLoginUser; - } - - @SneakyThrows - protected ScopeResource[] fetchScopeResourcesByDescriptorPairs(final String descriptPairsVarName) { - final var requestedScopes = ScenarioTest.getTypedVariable("scopes", Pair[].class); - final var existingScopesJson = withTitle("Fetch Available Account Scopes", () -> - httpGet(asGlobalAgent(), "/api/hs/accounts/scopes").expecting(OK).expecting(JSON) - ).getResponse().body(); - final var existingScopes = objectMapper.readValue(existingScopesJson, ScopeResource[].class); - return Arrays.stream(requestedScopes) - .map(pair -> Arrays.stream(existingScopes) - .filter(scope -> scope.getType().equals(pair.getLeft()) - && scope.getQualifier().equals(pair.getRight())) - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "No matching scope found for type=" + pair.getLeft() - + " and qualifier=" + pair.getRight())) - ) - .toArray(ScopeResource[]::new); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForExistingPerson.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForExistingPerson.java similarity index 52% rename from src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForExistingPerson.java rename to src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForExistingPerson.java index 43c959b0..9518d4cf 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForExistingPerson.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForExistingPerson.java @@ -9,42 +9,31 @@ import org.springframework.http.HttpStatus; import static io.restassured.http.ContentType.JSON; import static org.springframework.http.HttpStatus.OK; -public class CreateProfileForExistingPerson extends BaseProfileUseCase { +public class CreateAccountForExistingPerson extends BaseAccountUseCase { - public CreateProfileForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { + public CreateAccountForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { super(testSuite, asLoginUser); - introduction("A set of profile contains the login data for an RBAC subject."); + introduction("An account combines an RBAC subject with a natural person and thus grant's access to data in hsadmin-NG."); } @Override protected HttpResponse run() { obtain("Person: %{personGivenName} %{personFamilyName}", () -> - httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}") + httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}&type=%{personGivenType}") .expecting(OK).expecting(JSON), response -> response.expectArrayElements(1).getFromBody("[0].uuid"), "In real situations we have more precise measures to find the related person." ); - given("resolvedScopes", - fetchScopeResourcesByDescriptorPairs("scopes") - ); - - return obtain("newProfile", () -> - httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody(""" + return obtain("newAccount", () -> + httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody(""" { "person.uuid": ${Person: %{personGivenName} %{personFamilyName}}, - "nickname": ${nickname}, - "emailAddress": ${emailAddress}, - "smsNumber": ${smsNumber}, - "password": ${password}, - "totpSecrets": @{totpSecrets}, - "phonePassword": ${phonePassword}, + "subjectName": ${subjectName}, "globalUid": %{globalUid}, - "globalGid": %{globalGid}, - "active": %{active}, - "scopes": @{resolvedScopes} + "globalGid": %{globalGid} } """)) .expecting(HttpStatus.CREATED).expecting(ContentType.JSON) @@ -52,15 +41,14 @@ public class CreateProfileForExistingPerson extends BaseProfileUseCase.HttpResponse response) { + protected void verify(final UseCase.HttpResponse response) { verify( - "Verify the new Profile", - () -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}") + "Verify the new Account", + () -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}") .expecting(OK).expecting(JSON), - path("uuid").contains("%{newProfile}"), - path("nickname").contains("%{nickname}"), - path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"), - path("totpSecrets").contains("@{totpSecrets}") + path("uuid").contains("%{newAccount}"), + path("subjectName").contains("%{subjectName}"), + path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}") ); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForNewPerson.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForNewPerson.java similarity index 57% rename from src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForNewPerson.java rename to src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForNewPerson.java index 79b9a329..906c929e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfileForNewPerson.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateAccountForNewPerson.java @@ -9,23 +9,19 @@ import org.springframework.http.HttpStatus; import static io.restassured.http.ContentType.JSON; import static org.springframework.http.HttpStatus.OK; -public class CreateProfileForNewPerson extends BaseProfileUseCase { +public class CreateAccountForNewPerson extends BaseAccountUseCase { - public CreateProfileForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { + public CreateAccountForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { super(testSuite, asLoginUser); - introduction("A set of profile contains the login data for an RBAC subject."); + introduction("An account combines an RBAC subject with a natural person and thus grant's access to data in hsadmin-NG."); } @Override protected HttpResponse run() { - given("resolvedScopes", - fetchScopeResourcesByDescriptorPairs("scopes") - ); - - return obtain("newProfile", () -> - httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody(""" + return obtain("newAccount", () -> + httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody(""" { "person": { "personType": "NATURAL_PERSON", @@ -34,16 +30,9 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase.HttpResponse response) { + protected void verify(final UseCase.HttpResponse response) { obtain("Person: %{personGivenName} %{personFamilyName}", () -> httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}") .expecting(OK).expecting(JSON), @@ -60,13 +49,12 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}") + "Verify the new Account", + () -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}") .expecting(OK).expecting(JSON), - path("uuid").contains("%{newProfile}"), - path("nickname").contains("%{nickname}"), - path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"), - path("totpSecrets").contains("@{totpSecrets}") + path("uuid").contains("%{newAccount}"), + path("subjectName").contains("%{subjectName}"), + path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}") ); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java index 9afa4254..5c2a16e9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java @@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase; import static io.restassured.http.ContentType.JSON; import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent; -import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.bearerTemplate; +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static org.assertj.core.api.Assertions.assertThat; @@ -33,10 +33,7 @@ public class CurrentLoginUser extends UseCase { return obtain( "Current Login User", () -> - httpGet( - "/api/hs/accounts/current", req -> req - .header("Authorization", bearerTemplate("%{subjectName}")) - ) + httpGet( asSubject("%{subjectName}"), "/api/hs/accounts/current") .expecting(OK).expecting(JSON).expectObject() .extractValue("subject.name", "returnedSubjectName") .extractValue("person.givenName", "returnedGivenName") diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java index d91ebb86..44b4b767 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java @@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase; import java.util.List; import static io.restassured.http.ContentType.JSON; -import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.bearerTemplate; +import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; @@ -26,9 +26,8 @@ public class FetchRbacContext extends UseCase { protected HttpResponse run() { return obtain( "RBAC Context", () -> - httpGet( + httpGet( asSubject("%{subjectName}"), "/api/rbac/context", req -> req - .header("Authorization", bearerTemplate("%{subjectName}")) .header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS)) ) .expecting(OK).expecting(JSON).expectObject() diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java deleted file mode 100644 index fa612d3d..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java +++ /dev/null @@ -1,186 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts.scenarios; - -import lombok.SneakyThrows; -import net.hostsharing.hsadminng.hs.scenarios.Produces; -import net.hostsharing.hsadminng.hs.scenarios.Requires; -import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; -import net.hostsharing.hsadminng.mapper.Array; -import org.apache.commons.lang3.tuple.Pair; -import org.junit.jupiter.api.BeforeEach; -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.TestInfo; -import org.junit.jupiter.api.TestMethodOrder; -import org.springframework.http.HttpStatus; - -import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.as; -import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent; - -class ProfileScenarioTests extends ScenarioTest { - - @SneakyThrows - @BeforeEach - protected void beforeScenario(final TestInfo testInfo) { - super.beforeScenario(testInfo); - } - - @Nested - @Order(10) - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class RbacContextScenarios { - - @Test - @Order(1010) - @Produces("RBAC Context") - void shouldFetchRbacContext() { - new FetchRbacContext(scenarioTest) - .given("subjectName", "superuser-fran@hostsharing.net") - .given("assumedRoles", "rbactest.package#xxx00:ADMIN;rbactest.package#yyy00:ADMIN") - .given("expectedToBeGlobalAdmin", true) - .thenExpect(HttpStatus.OK) - .keep(); - } - } - - @Nested - @Order(20) - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class CurrentLoginUserScenarios { - - @Test - @Order(2010) - @Produces("Current Login User") - void shouldFetchCurrentLoginUser() { - new CurrentLoginUser(scenarioTest) - .given("subjectName", "superuser-fran@hostsharing.net") - .given("personGivenName", "Fran") - .given("expectedToBeGlobalAdmin", true) - .thenExpect(HttpStatus.OK) - .keep(); - } - } - - @Nested - @Order(30) - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class ProfileScenarios { - - @Test - @Order(1010) - @Produces( - explicitly = "Profile: susan-firby", - implicitly = {"Person: Susan Firby"}) - void shouldCreateInitialProfileForExistingNaturalPerson() { - new CreateProfileForExistingPerson(scenarioTest, asGlobalAgent()) - // to find a specific existing person - .given("personFamilyName", "Firby") - .given("personGivenName", "Susan") - // a login name, to be stored in the new RBAC subject - .given("nickname", "firby-susan") - // initial profile - .given("emailAddress", "susan.firby@example.com") - .given("smsNumber", "+49123456789") - .given("password", "my raw password") - .given("totpSecrets", Array.of("initialSecret")) - .given("phonePassword", "securePass123") - .given("globalUid", 21011) - .given("globalGid", 21011) - .given("active", true) - .given( - "scopes", Array.of( - Pair.of("HSADMIN", "prod") - )) - .thenExpect(HttpStatus.OK) - .keep(); - } - - @Test - @Order(1020) - @Requires("Profile: susan-firby") - void naturalPersonShouldBeAbleToUpdateTheirOwnProfile() { - new UpdateProfile(scenarioTest, as("firby-susan")) - // the profile to update - .given("profileUuid", "%{Profile: susan-firby}") - // updated profile - .given("active", false) - .given("totpSecrets", Array.of("initialSecret", "additionalSecret")) - .given("emailAddress", "susan.firby@example.org") - .given("password", "my new raw password") - .given("phonePassword", "securePass987") - .given("smsNumber", "+49987654321") - .given("scopes", Array.of(Pair.of("HSADMIN", "prod"), Pair.of("SSH", "external"))) - .thenExpect(HttpStatus.OK); - } - - @Test - @Order(1100) - @Produces( - explicitly = "Profile: peter-newman", - implicitly = {"Person: Peter Newman"}) - void shouldCreateInitialProfileForNewNaturalPerson() { - new CreateProfileForNewPerson(scenarioTest, asGlobalAgent()) - // to find a specific existing person - .given("personFamilyName", "Newman") - .given("personGivenName", "Peter") - // a login name, to be stored in the new RBAC subject - .given("nickname", "newman-peter") - // initial profile - .given("emailAddress", "peter.newman@example.com") - .given("smsNumber", "+49123456789") - .given("password", "my raw password") - .given("totpSecrets", Array.of("initialSecret")) - .given("phonePassword", "securePass123") - .given("globalUid", 21012) - .given("globalGid", 21012) - .given("active", true) - .given("scopes", Array.of(Pair.of("HSADMIN", "prod"))) - .thenExpect(HttpStatus.OK) - .keep(); - } - - @Test - @Order(1110) - @Requires("Profile: peter-newman") - void newNaturalPersonShouldBeAbleToUpdateTheirOwnProfile() { - new UpdateProfile(scenarioTest, as("newman-peter")) - // the profile to update - .given("profileUuid", "%{Profile: peter-newman}") - // updated profile - .given("active", false) - .given("totpSecrets", Array.of("initialSecret", "additionalSecret")) - .given("emailAddress", "peter.newman@example.org") - .given("password", "my new raw password") - .given("phonePassword", "securePass987") - .given("smsNumber", "+49987654321") - .given( - "scopes", Array.of( - Pair.of("HSADMIN", "prod"), - Pair.of("SSH", "external") - )) - .thenExpect(HttpStatus.OK); - } - - @Test - @Order(1120) - @Requires({"Profile: peter-newman", "Profile: susan-firby"}) - // Usually, scenario tests just test positive cases, but in the case of account profiles, security is extra important, - // thus I've added also some negative cases like this one for documentation reasons. - // More negative cases are tested in "so-called" Acceptance and in RestTests. - void anotherNaturalPersonShouldNotBeAbleToUpdateOthersProfile() { - new UpdateProfile(scenarioTest, as("firby-susan")) - // the profile to update - .given("profileUuid", "%{Profile: peter-newman}") - // updated profile - .given("active", false) - .given("totpSecrets", Array.of("initialSecret", "additionalSecret")) - .given("emailAddress", "peter.newman@example.org") - .given("password", "my new raw password") - .given("phonePassword", "securePass987") - .given("smsNumber", "+49987654321") - .given("scopes", Array.of(Pair.of("HSADMIN", "prod"), Pair.of("SSH", "external"))) - .thenExpect(HttpStatus.FORBIDDEN); - } - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/UpdateProfile.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/UpdateProfile.java deleted file mode 100644 index 07bec358..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/UpdateProfile.java +++ /dev/null @@ -1,65 +0,0 @@ -package net.hostsharing.hsadminng.hs.accounts.scenarios; - -import io.restassured.http.ContentType; -import lombok.val; -import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser; -import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; -import net.hostsharing.hsadminng.hs.scenarios.UseCase; -import org.springframework.http.HttpStatus; - -import static io.restassured.http.ContentType.JSON; -import static org.assertj.core.api.Fail.fail; -import static org.springframework.http.HttpStatus.OK; - -public class UpdateProfile extends BaseProfileUseCase { - - public UpdateProfile(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { - super(testSuite, asLoginUser); - - introduction("A set of profile contains the login data for an RBAC subject."); - } - - @Override - protected HttpResponse run(final HttpStatus expectedStatus) { - - given("resolvedScopes", - fetchScopeResourcesByDescriptorPairs("scopes") - ); - - return withTitle("Patch the Changes to the existing Profile", () -> { - val response = httpPatch( - asLoginUser, "/api/hs/accounts/profiles/%{profileUuid}", usingJsonBody(""" - { - "active": %{active}, - "totpSecrets": @{totpSecrets}, - "emailAddress": ${emailAddress}, - "phonePassword": ${phonePassword}, - "smsNumber": ${smsNumber}, - "scopes": @{resolvedScopes} - } - """)) - .reportWithResponse().expecting(expectedStatus); - - return switch (expectedStatus) { - case OK -> response.expecting(ContentType.JSON) - .extractValue("nickname", "nickname") - .extractValue("totpSecrets", "totpSecrets"); - case FORBIDDEN -> response.expecting(ContentType.JSON); - default -> fail("unexpected response: " + response); - }; - } - ); - } - - @Override - protected void verify(final UseCase.HttpResponse response) { - verify( - "Verify the Patched Profile", - () -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{profileUuid}") - .expecting(OK).expecting(JSON), - path("uuid").contains("%{newProfile}"), - path("nickname").contains("%{nickname}"), - path("totpSecrets").contains("%{totpSecrets}") - ); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java index 42f9de18..4b5df4de 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java @@ -360,8 +360,9 @@ class HsDomainSetupHostingAssetValidatorUnitTest { @Test void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() { - domainSetupFor("sub.sub.example.org").notRegistered() - .isRejectedWithCauseDomainNameNotFound("sub.example.org"); + Dns.fakeResultForDomain("unreg.example.org", DOMAIN_NOT_REGISTERED); + domainSetupFor("sub.unreg.example.org").notRegistered() + .isRejectedWithCauseDomainNameNotFound("unreg.example.org"); } @Test 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 c5654106..0252e03e 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 @@ -72,6 +72,21 @@ class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup .body("", hasSize(17)); // @formatter:on } + @Test + void globalAdmin_withoutAssumedRoles_canViewAllPersons_byNameAndPersonType() { + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer("superuser-alex@hostsharing.net")) + .port(port) + .when() + .get("http://localhost/api/hs/office/persons") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", hasSize(17)); + // @formatter:on + } } @Nested diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/FakeLoginUser.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/FakeLoginUser.java index 3e5cd145..84753b03 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/FakeLoginUser.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/FakeLoginUser.java @@ -1,17 +1,20 @@ package net.hostsharing.hsadminng.hs.scenarios; import lombok.AllArgsConstructor; +import lombok.val; import net.hostsharing.hsadminng.config.JwtFakeBearer; +import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; + @AllArgsConstructor public class FakeLoginUser { - final static String GLOBAL_AGENT = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented private String name; - public static FakeLoginUser as(final String name) { - return new FakeLoginUser(name); + public static FakeLoginUser asSubject(final String name) { + val resolvedName = ScenarioTest.resolve( name, DROP_COMMENTS); + return new FakeLoginUser(resolvedName); } public static FakeLoginUser asGlobalAgent() { @@ -21,4 +24,8 @@ public class FakeLoginUser { public String bearer() { return JwtFakeBearer.bearer(name); } + + String name() { + return name; + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/MarkdownTableCellRenderer.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/MarkdownTableCellRenderer.java new file mode 100644 index 00000000..d4601b11 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/MarkdownTableCellRenderer.java @@ -0,0 +1,63 @@ +package net.hostsharing.hsadminng.hs.scenarios; + +import lombok.experimental.UtilityClass; +import lombok.val; + +@UtilityClass +public class MarkdownTableCellRenderer { + + public static String toMarkdownTableCell(final Object value) { + val raw = String.valueOf(value); + if (raw.isEmpty()) { + return ""; + } + + val normalized = raw.replace("\r\n", "\n").replace("\r", "\n"); + val lines = normalized.split("\n", -1); + val out = new StringBuilder(normalized.length() + (lines.length * 4)); + + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + out.append("
"); + } + out.append(escapeWithIndent(lines[i])); + } + + return out.toString(); + } + + private String escapeWithIndent(final String line) { + int i = 0; + while (i < line.length()) { + final char c = line.charAt(i); + if (c == ' ' || c == '\t') { + i++; + } else { + break; + } + } + + val out = new StringBuilder(line.length() + 16); + for (int j = 0; j < i; j++) { + out.append(line.charAt(j) == '\t' ? "  " : " "); + } + + for (int j = i; j < line.length(); j++) { + final char c = line.charAt(j); + if (c == '&') { + out.append("&"); + } else if (c == '<') { + out.append("<"); + } else if (c == '>') { + out.append(">"); + } else if (c == '|') { + out.append("|"); + } else { + out.append(c); + } + } + + return out.toString(); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java index 62221379..30b690a6 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java @@ -274,17 +274,11 @@ public abstract class ScenarioTest extends ContextBasedTest { //noinspection unchecked return (T) new BigDecimal(resolvedValue); } + if (valueType == Integer.class) { + //noinspection unchecked + return (T) Integer.valueOf(resolvedValue); + } //noinspection unchecked return (T) resolvedValue; } - - public static T getTypedVariable(final String varName, final Class expectedValueClass) { - final var value = knowVariables().get(varName); - if (value != null && !expectedValueClass.isAssignableFrom(value.getClass())) { - throw new IllegalArgumentException("variable '" + varName + "'" + - " expected to be of type " + expectedValueClass + " " + - " but got " + value.getClass()); - } - return (T) value; - } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java index 028e679a..2ad37c2b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java @@ -27,7 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; public class TestReport { public static final File BUILD_DOC_SCENARIOS = new File("build/doc/scenarios"); - public static final SimpleDateFormat MM_DD_YYYY_HH_MM_SS = new SimpleDateFormat("MM-dd-yyyy hh:mm:ss"); + public static final SimpleDateFormat YYYY_MM_DD_HH_MM_SS = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); private static final File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md"); private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build(); @@ -96,7 +96,7 @@ public class TestReport { public void close() { if (markdownReport != null) { printPara("---"); - printPara("generated on " + MM_DD_YYYY_HH_MM_SS.format(new Date()) + " for branch " + currentGitBranch()); + printPara("generated on " + YYYY_MM_DD_HH_MM_SS.format(new Date()) + " for branch " + currentGitBranch()); markdownReport.close(); System.out.println("SCENARIO REPORT: " + asClickableLink(markdownReportFile)); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java index 701e2942..79dd23c9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java @@ -35,6 +35,7 @@ import java.util.function.Supplier; import static java.net.URLEncoder.encode; import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.hs.scenarios.MarkdownTableCellRenderer.toMarkdownTableCell; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS; import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached; @@ -46,7 +47,7 @@ import static org.junit.platform.commons.util.StringUtils.isNotBlank; public abstract class UseCase> { private static final HttpClient client = HttpClient.newHttpClient(); - private static final int HTTP_TIMEOUT_SECONDS = 20; // FIXME: configurable in environment + private static final int HTTP_TIMEOUT_SECONDS = 20; // TODO.test: configurable in environment protected final ObjectMapper objectMapper = new ObjectMapper(); protected final ScenarioTest testSuite; @@ -54,6 +55,7 @@ public abstract class UseCase> { private final Map>> requirements = new LinkedMap<>(); private final String resultAlias; private final Map givenProperties = new LinkedHashMap<>(); + private final Map expectedProperties = new LinkedHashMap<>(); private String nextTitle; // just temporary to override resultAlias for sub-use-cases private String introduction; @@ -79,13 +81,10 @@ public abstract class UseCase> { if (introduction != null) { testReport.printPara(introduction); } - testReport.printPara("### Given Properties"); - testReport.printLine(""" - | name | value | - |------|-------|"""); - givenProperties.forEach((key, value) -> - testReport.printLine("| " + key + " | " + value.toString().replace("\n", "
") + " |")); - testReport.printLine(""); + testReport.printPara("### Properties"); + renderProperties("Given", givenProperties); + renderProperties("Expected", expectedProperties); + testReport.silent(() -> requirements.forEach((alias, factory) -> { final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS); @@ -106,6 +105,21 @@ public abstract class UseCase> { return response; } + private void renderProperties(final String title, final Map properties) { + if (properties.isEmpty()) { + return; + } + testReport.printLine(""); + testReport.printLine("#### " + title); + testReport.printLine(""); + testReport.printLine(""" + | name | value | + |------|-------|"""); + properties.forEach((key, value) -> + testReport.printLine("| " + key + " | " + toMarkdownTableCell(value) + " |")); + testReport.printLine(""); + } + // this method is called by the test framework, override, but do not call from subclass protected HttpResponse run(final HttpStatus expectedStatus) { assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK); @@ -129,6 +143,15 @@ public abstract class UseCase> { return this; } + // To keep things simple, both given and expected properties are available everywhere in all templates. + // The distinction is mostly for readability. + // It would be a bit tricky to make the expected values available just for validations. + public final UseCase expected(final String propName, final Object propValue) { + expectedProperties.put(propName, ScenarioTest.resolve(propValue == null ? null : propValue.toString(), TemplateResolver.Resolver.KEEP_COMMENTS)); + ScenarioTest.putProperty(propName, propValue); + return this; + } + public final JsonTemplate usingJsonBody(final String jsonTemplate) { return new JsonTemplate(jsonTemplate); } @@ -166,23 +189,25 @@ public abstract class UseCase> { @SneakyThrows public final HttpResponse httpGet( + final FakeLoginUser loginUser, final String uriPathWithPlaceholder, final Function requestCustomizer) { final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS); final var requestBuilder = HttpRequest.newBuilder() .GET() .uri(new URI("http://localhost:" + testSuite.port + uriPath)) - .timeout(seconds(HTTP_TIMEOUT_SECONDS)); + .header("Authorization", loginUser.bearer()) + .header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]"); final var customizedRequestBuilder = requestCustomizer.apply(requestBuilder); final var request = customizedRequestBuilder.build(); final var response = client.send(request, BodyHandlers.ofString()); - return new HttpResponse(HttpMethod.GET, uriPath, null, response); + return new HttpResponse(HttpMethod.GET, uriPath, null, response, null); } - @SneakyThrows - public final HttpResponse httpGet(final FakeLoginUser loginUser, final String uriPathWithPlaceholders) { - return httpGet(uriPathWithPlaceholders, - req -> req.header("Authorization", loginUser.bearer())); + public final HttpResponse httpGet( + final FakeLoginUser loginUser, + final String uriPathWithPlaceholder) { + return httpGet(loginUser, uriPathWithPlaceholder, requestBuilder -> requestBuilder); } @SneakyThrows @@ -196,10 +221,11 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("Authorization", loginUser.bearer()) + .header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]") .timeout(seconds(HTTP_TIMEOUT_SECONDS)) .build(); final var response = client.send(request, BodyHandlers.ofString()); - return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response); + return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response, loginUser); } @SneakyThrows @@ -214,10 +240,11 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("Authorization", loginUser.bearer()) + .header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]") .timeout(seconds(HTTP_TIMEOUT_SECONDS)) .build(); final var response = client.send(request, BodyHandlers.ofString()); - return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response); + return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response, loginUser); } @SneakyThrows @@ -228,10 +255,11 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("Authorization", loginUser.bearer()) + .header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]") .timeout(seconds(HTTP_TIMEOUT_SECONDS)) .build(); final var response = client.send(request, BodyHandlers.ofString()); - return new HttpResponse(HttpMethod.DELETE, uriPath, null, response); + return new HttpResponse(HttpMethod.DELETE, uriPath, null, response, loginUser); } protected PathAssertion path(final String path) { @@ -289,9 +317,11 @@ public abstract class UseCase> { public final class HttpResponse { + private static final String AUTH_HEADER_KEY = "Authorization"; private final HttpMethod httpMethod; private final String uri; private final String requestBody; + private final @Nullable String authUserName; @Getter private final java.net.http.HttpResponse response; @@ -310,12 +340,14 @@ public abstract class UseCase> { final HttpMethod httpMethod, final String uri, final String requestBody, - final java.net.http.HttpResponse response + final java.net.http.HttpResponse response, + final @Nullable FakeLoginUser loginUser ) { this.httpMethod = httpMethod; this.uri = uri; this.requestBody = requestBody; this.response = response; + this.authUserName = loginUser == null ? null : loginUser.name(); this.status = HttpStatus.valueOf(response.statusCode()); if (this.status == HttpStatus.CREATED) { final var location = response.headers().firstValue("Location").orElseThrow(); @@ -386,6 +418,10 @@ public abstract class UseCase> { return this; } + public String getBody() { + return response.body(); + } + @SneakyThrows public V getFromBody(final String path) { final var body = response.body(); @@ -431,7 +467,8 @@ public abstract class UseCase> { // the request testReport.printLine("```"); - testReport.printLine(httpMethod.name() + " " + uri); + testReport.printLine(httpMethod.name() + " '" + uri + "'"); + printRequestHeaders(); testReport.printJson(requestBody); // the response @@ -446,6 +483,33 @@ public abstract class UseCase> { return this; } + private void printRequestHeaders() { + final var request = response.request(); + if (request == null) { + return; + } + request.headers().map().entrySet().stream() + .sorted(Map.Entry.comparingByKey(String.CASE_INSENSITIVE_ORDER)) + // the Authorization header with the long Bearer token is of no value here + .filter(entry -> !AUTH_HEADER_KEY.equalsIgnoreCase(entry.getKey())) + // instead, use the X-Fake-Authorization header as if it was the real Authorization header + .map(entry -> Map.entry(AUTH_HEADER_KEY, entry.getValue())) + .forEach(entry -> { + testReport.printLine("- " + entry.getKey() + ": " + headerValue(entry)); + }); + } + + private String headerValue(final Map.Entry> entry) { + if ("X-Fake-Authorization".equalsIgnoreCase(entry.getKey())) { + return fakeAuthorizationValue(); + } + return String.join(", ", entry.getValue()); + } + + private String fakeAuthorizationValue() { + return authUserName == null ? "" : "Bearer <" + authUserName + ">"; + } + @SneakyThrows private void optionallyReportRequestAndResponse() { if (!reportGenerated) { diff --git a/src/test/java/net/hostsharing/hsadminng/repr/StringifyableUnitTest.java b/src/test/java/net/hostsharing/hsadminng/repr/StringifyableUnitTest.java new file mode 100644 index 00000000..63ecc9e6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/repr/StringifyableUnitTest.java @@ -0,0 +1,81 @@ +package net.hostsharing.hsadminng.repr; + +import lombok.val; +import net.hostsharing.hsadminng.persistence.BaseEntity; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +class StringifyableUnitTest { + + @Test + void toShortString_whenEntityImplementsStringifyable_usesItsToShortString() { + // given + val entity = new StringifyableTestEntity( + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "short-repr" + ); + + // when + val result = Stringifyable.toShortString(entity); + + // then + assertEquals("short-repr", result); + } + + @Test + void toShortString_whenEntityDoesNotImplementStringifyable_returnsUuidString() { + // given + val entity = new NonStringifyableTestEntity(UUID.fromString("00000000-0000-0000-0000-000000000002")); + + // when + val result = Stringifyable.toShortString(entity); + + // then + assertEquals("00000000-0000-0000-0000-000000000002", result); + } + + private static final class NonStringifyableTestEntity implements BaseEntity { + private final UUID uuid; + + private NonStringifyableTestEntity(final UUID uuid) { + this.uuid = uuid; + } + + @Override + public UUID getUuid() { + return uuid; + } + + @Override + public int getVersion() { + return 0; + } + } + + private static final class StringifyableTestEntity implements BaseEntity, Stringifyable { + private final UUID uuid; + private final String shortString; + + private StringifyableTestEntity(final UUID uuid, final String shortString) { + this.uuid = uuid; + this.shortString = shortString; + } + + @Override + public UUID getUuid() { + return uuid; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String toShortString() { + return shortString; + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/JsonMatcher.java b/src/test/java/net/hostsharing/hsadminng/test/JsonMatcher.java index cf98f08b..b04b03df 100644 --- a/src/test/java/net/hostsharing/hsadminng/test/JsonMatcher.java +++ b/src/test/java/net/hostsharing/hsadminng/test/JsonMatcher.java @@ -45,11 +45,13 @@ public class JsonMatcher extends BaseMatcher { @Override public boolean matches(final Object actual) { - if (actual == null || actual.getClass().isAssignableFrom(CharSequence.class)) { + if (actual == null) { return false; } try { - final var actualJson = new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual); + var actualJson = (actual instanceof CharSequence) + ? actual.toString() + : new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual); JSONAssert.assertEquals(expectedJson, actualJson, compareMode); return true; } catch (final JSONException | JsonProcessingException e) {