Story#5617: amend account module to Keycloak primary (#213)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/213
This commit is contained in:
@@ -75,13 +75,37 @@ alias gw-importHostingAssets='importLegacyData importHostingAssets'
|
|||||||
|
|
||||||
function gradlewBootRun() {
|
function gradlewBootRun() {
|
||||||
local serverPort=${1:-8080}; shift
|
local serverPort=${1:-8080}; shift
|
||||||
local managementPort=${2:-$((serverPort + 1))}; shift
|
local managementPort
|
||||||
local additional_args="$@"
|
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_ISSUER
|
||||||
unset HSADMINNG_JWT_JWKS_URL
|
unset HSADMINNG_JWT_JWKS_URL
|
||||||
unset HSADMINNG_JWT_TOKEN_URL
|
unset HSADMINNG_JWT_TOKEN_URL
|
||||||
set -x
|
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
|
set +x
|
||||||
}
|
}
|
||||||
alias gw-bootRun=gradlewBootRun
|
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 podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true'
|
||||||
|
|
||||||
alias gw=gradleWrapper
|
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-stop='docker stop hsadmin-ng-postgres'
|
||||||
alias pg-sql-start='docker container start hsadmin-ng-postgres'
|
alias pg-sql-start='docker container start hsadmin-ng-postgres'
|
||||||
alias pg-sql-remove='docker rm hsadmin-ng-postgres'
|
alias pg-sql-remove='docker rm hsadmin-ng-postgres'
|
||||||
|
|||||||
@@ -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:
|
# if the container has been built already and you want to keep the data, run this:
|
||||||
pg-sql-start
|
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`:
|
# on `localhost:8080` and the management server on `localhost:8081`:
|
||||||
gw-bootRun
|
gw-bootRun
|
||||||
|
|
||||||
# there is also an alias which takes an optional port as an argument:
|
# you can also pass optional arguments:
|
||||||
gw-bootRun 8888
|
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
|
- **dev**: the PostgreSQL users are created via Liquibase
|
||||||
- **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server
|
- **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server
|
||||||
- **complete**: all modules are started
|
- **complete**: all modules (rbac, office, account, hosting) are started
|
||||||
- **test-data**: some test data inserted
|
- **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.
|
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).
|
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:
|
# the following command should return a JSON array with just all customers:
|
||||||
jwt-curl GET http://localhost:8080/api/test/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:
|
# 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'
|
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
|
| jq
|
||||||
jwt-curl UNASSUME
|
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 \
|
jwt-curl POST \
|
||||||
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
|
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
|
||||||
http://localhost:8080/api/test/customers \
|
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:
|
Initially, pull an image compatible to the current PostgreSQL version of Hostsharing:
|
||||||
|
|
||||||
docker pull postgres:15.5-bookworm
|
docker pull postgres:17.7-trixie
|
||||||
|
|
||||||
<big>**⚠**</big>
|
<big>**⚠**</big>
|
||||||
If we switch the version, please also amend the documentation as well as the aliases file. Thanks!
|
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:
|
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:
|
# or via alias:
|
||||||
pg-sql-run
|
pg-sql-run
|
||||||
@@ -705,14 +712,15 @@ These profiles mean:
|
|||||||
|
|
||||||
### How to Run the Application in a Debugger
|
### 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
|
```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
|
> Listening for transport dt_socket at address: 5005
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,11 +14,11 @@ if [ "$1" == "--trace" ]; then
|
|||||||
function doCurl() {
|
function doCurl() {
|
||||||
set -x
|
set -x
|
||||||
if [ -z "$HSADMINNG_JWT_ASSUME" ]; then
|
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" \
|
--header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
|
||||||
"$@"
|
"$@"
|
||||||
else
|
else
|
||||||
curl --fail-with-body \
|
curl --no-progress-meter --show-error --fail-with-body \
|
||||||
--header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
|
--header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
|
||||||
--header "assumed-roles: $HSADMINNG_JWT_ASSUME" \
|
--header "assumed-roles: $HSADMINNG_JWT_ASSUME" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ plugins {
|
|||||||
java
|
java
|
||||||
id("org.springframework.boot") version "3.5.5"
|
id("org.springframework.boot") version "3.5.5"
|
||||||
id("io.spring.dependency-management") version "1.1.7" // manages implicit dependencies
|
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("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("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
|
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
|
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:
|
// HOWTO: find out which dependency versions are managed by Spring Boot:
|
||||||
// https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
|
// https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
|
||||||
|
|
||||||
@@ -702,3 +707,16 @@ tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
|
|||||||
// Or always enable debug (remove the if condition)
|
// Or always enable debug (remove the if condition)
|
||||||
// jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005")
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# Gradle Java Toolchain-support
|
# Gradle Java Toolchain-support
|
||||||
|
org.gradle.toolchain.auto-download=true
|
||||||
org.gradle.java.installations.auto-detect=true
|
org.gradle.java.installations.auto-detect=true
|
||||||
org.gradle.java.installations.auto-download=true
|
org.gradle.java.installations.auto-download=true
|
||||||
# org.gradle.jvm.toolchain.install.adoptopenjdk.baseUri
|
# org.gradle.jvm.toolchain.install.adoptopenjdk.baseUri
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
||||||
@@ -21,8 +22,9 @@ public class FakeJwtController {
|
|||||||
@PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
@PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
@Timed("app.config.jwt.token")
|
@Timed("app.config.jwt.token")
|
||||||
public ResponseEntity<Map<String, Object>> token(
|
public ResponseEntity<Map<String, Object>> token(
|
||||||
@RequestParam String username,
|
HttpServletRequest request,
|
||||||
@RequestParam String password,
|
@RequestParam(name = "username", required = false) String username,
|
||||||
|
@RequestParam(name = "password", required = false) String password,
|
||||||
@RequestParam(defaultValue = "openid profile") String scope) {
|
@RequestParam(defaultValue = "openid profile") String scope) {
|
||||||
|
|
||||||
log.info("Fake JWT: Issuing token for user: {}", username);
|
log.info("Fake JWT: Issuing token for user: {}", username);
|
||||||
|
|||||||
+6
-4
@@ -1,12 +1,13 @@
|
|||||||
package net.hostsharing.hsadminng.context;
|
package net.hostsharing.hsadminng.context;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
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;
|
import java.io.IOException;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -18,6 +19,7 @@ public class HttpServletRequestBodyCachingFilter extends OncePerRequestFilter {
|
|||||||
final HttpServletResponse response,
|
final HttpServletResponse response,
|
||||||
final FilterChain filterChain)
|
final FilterChain filterChain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
filterChain.doFilter(new HttpServletRequestWithCachedBody(request), response);
|
|
||||||
|
filterChain.doFilter(new ContentCachingRequestWrapper(request), response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AccountResource> 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<List<AccountResource>> 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<AccountResource> 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<Void> 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<CurrentLoginUserResource> 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<HsAccountEntity> 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<HsAccountEntity, AccountResource> 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<AccountInsertResource, HsAccountEntity> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HsAccountEntity>, Stringifyable {
|
||||||
|
|
||||||
|
protected static Stringify<HsAccountEntity> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HsAccountEntity, UUID> {
|
||||||
|
|
||||||
|
@Timed("app.login.account.repo.findByUuid")
|
||||||
|
Optional<HsAccountEntity> findByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
@Timed("app.login.account.repo.findByPerson")
|
||||||
|
List<HsAccountEntity> 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<HsAccountEntity> findByCurrentSubject();
|
||||||
|
|
||||||
|
@Timed("app.login.account.repo.save")
|
||||||
|
HsAccountEntity save(final HsAccountEntity entity);
|
||||||
|
}
|
||||||
@@ -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<ProfileResource> 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<List<ProfileResource>> 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<ProfileResource> 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<Void> 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<ProfileResource> 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<CurrentLoginUserResource> 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<HsProfileScopeRealEntity> 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<HsProfileEntity> 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<HsProfileEntity, ProfileResource> 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<ScopeResource> 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<ProfileInsertResource, HsProfileEntity> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileEntity>, Stringifyable {
|
|
||||||
|
|
||||||
protected static Stringify<HsProfileEntity> 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<String> 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<HsProfileScopeRealEntity> scopes;
|
|
||||||
|
|
||||||
public Set<HsProfileScopeRealEntity> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ProfilePatchResource> {
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileEntity, UUID> {
|
|
||||||
|
|
||||||
@Timed("app.login.profile.repo.findByUuid")
|
|
||||||
Optional<HsProfileEntity> findByUuid(final UUID uuid);
|
|
||||||
|
|
||||||
@Timed("app.login.profile.repo.findByPerson")
|
|
||||||
List<HsProfileEntity> 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<HsProfileEntity> findByCurrentSubject();
|
|
||||||
|
|
||||||
@Timed("app.login.profile.repo.save")
|
|
||||||
HsProfileEntity save(final HsProfileEntity entity);
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileScope> {
|
|
||||||
|
|
||||||
private static Stringify<HsProfileScope> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<List<ScopeResource>> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileScopeRbacEntity, UUID> {
|
|
||||||
|
|
||||||
@Timed("app.accounts.scope.repo.findAll.rbac")
|
|
||||||
List<HsProfileScopeRbacEntity> findAll();
|
|
||||||
|
|
||||||
@Timed("app.accounts.scope.repo.findByUuid.rbac")
|
|
||||||
Optional<HsProfileScopeRbacEntity> findByUuid(final UUID id);
|
|
||||||
|
|
||||||
@Timed("app.accounts.scope.repo.findByTypeAndQualifier.rbac")
|
|
||||||
Optional<HsProfileScopeRbacEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
|
|
||||||
|
|
||||||
@Timed("app.accounts.scope.repo.save.rbac")
|
|
||||||
HsProfileScopeRbacEntity save(final HsProfileScopeRbacEntity entity);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileScopeRealEntity, UUID> {
|
|
||||||
|
|
||||||
@Timed("app.account.scope.repo.findAll.real")
|
|
||||||
List<HsProfileScopeRealEntity> findAll();
|
|
||||||
|
|
||||||
@Timed("app.account.scope.repo.findByUuid.real")
|
|
||||||
Optional<HsProfileScopeRealEntity> findByUuid(final UUID id);
|
|
||||||
|
|
||||||
@Timed("app.account.scope.repo.findByTypeAndQualifier.real")
|
|
||||||
Optional<HsProfileScopeRealEntity> findByTypeAndQualifier(@NotNull String type, @NotNull String qualifier);
|
|
||||||
|
|
||||||
@Timed("app.account.scope.repo.save.real")
|
|
||||||
HsProfileScopeRealEntity save(final HsProfileScopeRealEntity entity);
|
|
||||||
}
|
|
||||||
@@ -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<HsProfileScopeRealEntity> mapProfileToScopeEntities(
|
|
||||||
final List<ScopeResource> resources
|
|
||||||
) {
|
|
||||||
final var entities = new HashSet<HsProfileScopeRealEntity>();
|
|
||||||
syncProfileScopeEntities(resources, entities);
|
|
||||||
return entities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void syncProfileScopeEntities(
|
|
||||||
final List<ScopeResource> resources,
|
|
||||||
final Set<HsProfileScopeRealEntity> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-5
@@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hs.office.person;
|
|||||||
|
|
||||||
import io.micrometer.core.annotation.Timed;
|
import io.micrometer.core.annotation.Timed;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
import lombok.val;
|
||||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
|
||||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi;
|
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.HsOfficePersonInsertResource;
|
||||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource;
|
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.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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -38,14 +40,23 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
|
|||||||
public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons(
|
public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons(
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final String name,
|
final String name,
|
||||||
|
final HsOfficePersonTypeResource type,
|
||||||
final UUID representedByPersonUuid) {
|
final UUID representedByPersonUuid) {
|
||||||
context.assumeRoles(assumedRoles);
|
context.assumeRoles(assumedRoles);
|
||||||
|
|
||||||
final var entities = representedByPersonUuid != null
|
val personType = type != null ? HsOfficePersonType.valueOf(type.name()) : null;
|
||||||
|
// @formatter:off
|
||||||
|
val entities = (
|
||||||
|
representedByPersonUuid != null
|
||||||
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
|
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
|
||||||
: personRepo.findPersonByOptionalNameLike(name);
|
: 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);
|
return ResponseEntity.ok(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -24,6 +24,19 @@ public interface RbacRoleRepository extends Repository<RbacRoleEntity, UUID> {
|
|||||||
@Timed("app.rbac.roles.repo.findByRoleName")
|
@Timed("app.rbac.roles.repo.findByRoleName")
|
||||||
RbacRoleEntity findByRoleName(String roleName);
|
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")
|
@Timed("app.rbac.roles.repo.fetchAssumedRoles")
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
|
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ public final class Stringify<B> {
|
|||||||
private class Property<B, V> {
|
private class Property<B, V> {
|
||||||
String name;
|
String name;
|
||||||
Function<B, V> getter;
|
Function<B, V> getter;
|
||||||
Function<V, ?> mapper; // FIXME: better generics?
|
Function<V, ?> mapper; // TODO.impl: better generics?
|
||||||
|
|
||||||
Property(String name, Function<B, V> getter) {
|
Property(String name, Function<B, V> getter) {
|
||||||
this(name, getter, v -> v);
|
this(name, getter, v -> v);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
package net.hostsharing.hsadminng.repr;
|
package net.hostsharing.hsadminng.repr;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||||
|
|
||||||
public interface Stringifyable {
|
public interface Stringifyable {
|
||||||
|
|
||||||
|
static String toShortString(final BaseEntity<?> entity) {
|
||||||
|
return entity instanceof Stringifyable stringifyableEntity
|
||||||
|
? stringifyableEntity.toShortString()
|
||||||
|
: entity.getUuid().toString();
|
||||||
|
}
|
||||||
|
|
||||||
String toShortString();
|
String toShortString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+11
-11
@@ -1,9 +1,9 @@
|
|||||||
get:
|
get:
|
||||||
summary: Returns a list of all profile.
|
summary: Returns a list of all account.
|
||||||
description: Returns the list of all profile which are visible to the current subject or any of it's assumed roles.
|
description: Returns the list of all account which are visible to the current subject or any of it's assumed roles.
|
||||||
tags:
|
tags:
|
||||||
- profile
|
- account
|
||||||
operationId: getListOfProfile
|
operationId: getListIfAccount
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||||
- name: personUuid
|
- name: personUuid
|
||||||
@@ -12,7 +12,7 @@ get:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -21,31 +21,31 @@ get:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
$ref: 'account-schemas.yaml#/components/schemas/Account'
|
||||||
"401":
|
"401":
|
||||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||||
"403":
|
"403":
|
||||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||||
|
|
||||||
post:
|
post:
|
||||||
summary: Adds a new profile.
|
summary: Adds a new account.
|
||||||
tags:
|
tags:
|
||||||
- profile
|
- account
|
||||||
operationId: postNewProfile
|
operationId: postNewAccount
|
||||||
requestBody:
|
requestBody:
|
||||||
description: A JSON object describing the new credential.
|
description: A JSON object describing the new credential.
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: 'profile-schemas.yaml#/components/schemas/ProfileInsert'
|
$ref: 'account-schemas.yaml#/components/schemas/AccountInsert'
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Created
|
description: Created
|
||||||
content:
|
content:
|
||||||
'application/json':
|
'application/json':
|
||||||
schema:
|
schema:
|
||||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
$ref: 'account-schemas.yaml#/components/schemas/Account'
|
||||||
"401":
|
"401":
|
||||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||||
"403":
|
"403":
|
||||||
@@ -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'
|
||||||
@@ -13,5 +13,5 @@ map:
|
|||||||
- type: string:uuid => java.util.UUID
|
- type: string:uuid => java.util.UUID
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/api/hs/accounts/profiles/{profileUuid}:
|
/api/hs/accounts/accounts/{accountUuid}:
|
||||||
null: org.openapitools.jackson.nullable.JsonNullable
|
null: org.openapitools.jackson.nullable.JsonNullable
|
||||||
|
|||||||
@@ -13,17 +13,11 @@ paths:
|
|||||||
/api/hs/accounts/current:
|
/api/hs/accounts/current:
|
||||||
$ref: "current.yaml"
|
$ref: "current.yaml"
|
||||||
|
|
||||||
# Scopes
|
# Account
|
||||||
|
|
||||||
/api/hs/accounts/scopes:
|
/api/hs/accounts/accounts/{accountUuid}:
|
||||||
$ref: "scopes.yaml"
|
$ref: "accout-with-uuid.yaml"
|
||||||
|
|
||||||
|
/api/hs/accounts/accounts:
|
||||||
# Profile
|
$ref: "accounts.yaml"
|
||||||
|
|
||||||
/api/hs/accounts/profiles/{profileUuid}:
|
|
||||||
$ref: "profile-with-uuid.yaml"
|
|
||||||
|
|
||||||
/api/hs/accounts/profiles:
|
|
||||||
$ref: "profiles.yaml"
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ get:
|
|||||||
summary: Currently logged in user data.
|
summary: Currently logged in user data.
|
||||||
description: Returns information about the currently logged in user.
|
description: Returns information about the currently logged in user.
|
||||||
tags:
|
tags:
|
||||||
- profile
|
- account
|
||||||
operationId: getCurrentLoginUser
|
operationId: getCurrentLoginUser
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -10,7 +10,7 @@ get:
|
|||||||
content:
|
content:
|
||||||
'application/json':
|
'application/json':
|
||||||
schema:
|
schema:
|
||||||
$ref: 'profile-schemas.yaml#/components/schemas/CurrentLoginUser'
|
$ref: 'account-schemas.yaml#/components/schemas/CurrentLoginUser'
|
||||||
"401":
|
"401":
|
||||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||||
"403":
|
"403":
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
@@ -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'
|
|
||||||
@@ -12,6 +12,12 @@ get:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Prefix of caption to filter the results.
|
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
|
- name: representedByPersonUuid
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ management:
|
|||||||
server:
|
server:
|
||||||
port: 8081
|
port: 8081
|
||||||
address: 127.0.0.1
|
address: 127.0.0.1
|
||||||
|
info:
|
||||||
|
build:
|
||||||
|
enabled: true
|
||||||
|
git:
|
||||||
|
enabled: true
|
||||||
|
mode: full
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
exposure:
|
||||||
@@ -96,5 +102,3 @@ spring:
|
|||||||
jwt:
|
jwt:
|
||||||
issuer-uri: "http://localhost:${server.port}/fake-jwt"
|
issuer-uri: "http://localhost:${server.port}/fake-jwt"
|
||||||
jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json"
|
jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
--changeset michael.hoennig:hs-profile-SCHEMA endDelimiter:--//
|
--changeset michael.hoennig:hs-accounts-SCHEMA endDelimiter:--//
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
CREATE SCHEMA hs_accounts;
|
CREATE SCHEMA hs_accounts;
|
||||||
--//
|
--//
|
||||||
|
|||||||
@@ -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,
|
uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred,
|
||||||
version int not null default 0,
|
version int not null default 0,
|
||||||
|
|
||||||
person_uuid uuid not null references hs_office.person(uuid),
|
person_uuid uuid not null references hs_office.person(uuid),
|
||||||
|
|
||||||
active bool,
|
|
||||||
global_uid int unique, -- w/o
|
global_uid int unique, -- w/o
|
||||||
global_gid 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
|
|
||||||
);
|
);
|
||||||
--//
|
--//
|
||||||
|
|
||||||
@@ -80,16 +22,12 @@ create table hs_accounts.scope_mapping
|
|||||||
--changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--//
|
--changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--//
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
call base.create_journal('hs_accounts.scope_mapping');
|
call base.create_journal('hs_accounts.account');
|
||||||
call base.create_journal('hs_accounts.scope');
|
|
||||||
call base.create_journal('hs_accounts.profile');
|
|
||||||
--//
|
--//
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
--changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--//
|
--changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--//
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
call base.tx_create_historicization('hs_accounts.scope_mapping');
|
call base.tx_create_historicization('hs_accounts.account');
|
||||||
call base.tx_create_historicization('hs_accounts.scope');
|
|
||||||
call base.tx_create_historicization('hs_accounts.profile');
|
|
||||||
--//
|
--//
|
||||||
|
|||||||
-41
@@ -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
|
|
||||||
|
|
||||||
```
|
|
||||||
+6
-59
@@ -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 $$
|
do language plpgsql $$
|
||||||
@@ -15,13 +15,6 @@ declare
|
|||||||
userDrewSubjectUuid uuid;
|
userDrewSubjectUuid uuid;
|
||||||
personDrewUuid 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
|
begin
|
||||||
call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN');
|
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');
|
userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org');
|
||||||
personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew');
|
personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew');
|
||||||
|
|
||||||
-- Add test scopes
|
-- Add test account (linking to assumed rbac.subject UUIDs)
|
||||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
INSERT INTO hs_accounts.account (uuid, version, person_uuid, global_uid, global_gid) VALUES
|
||||||
('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod', true, true)
|
( superuserAlexSubjectUuid, 0, personAlexUuid, 1001, 1001),
|
||||||
RETURNING * INTO scope_HSADMIN_prod;
|
( superuserFranSubjectUuid, 0, personFranUuid, 1002, 1002),
|
||||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
( userDrewSubjectUuid, 0, personDrewUuid, 1003, 1003);
|
||||||
('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);
|
|
||||||
|
|
||||||
end; $$;
|
end; $$;
|
||||||
--//
|
--//
|
||||||
|
|||||||
@@ -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.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht zugänglich
|
||||||
general.but-is=ist aber
|
general.but-is=ist aber
|
||||||
|
|
||||||
# profile validations
|
# account validations
|
||||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existierender Gültigkeitsbereich {0} passt nicht zum angegebenen {1}
|
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
|
||||||
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
|
account.only-natural-persons-allowed-but-{0}-is-{1}=Nur natürliche Personen sind erlaubt, aber {0} ist {1}
|
||||||
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
|
|
||||||
|
|
||||||
# office.coop-shares
|
# 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}
|
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1}
|
||||||
|
|||||||
@@ -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.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessible
|
||||||
general.but-is=but is
|
general.but-is=but is
|
||||||
|
|
||||||
# profile validations
|
# account validations
|
||||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1}
|
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
|
||||||
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
|
account.only-natural-persons-allowed-but-{0}-is-{1}=only natural persons allowed, but {0} is {1}
|
||||||
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
|
|
||||||
|
|
||||||
# office.coop-shares
|
# 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}
|
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1}
|
||||||
|
|||||||
@@ -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
|
general.but-is=mais c'est
|
||||||
|
|
||||||
# profile validations
|
# profile validations
|
||||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1}
|
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
|
||||||
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
|
account.only-natural-persons-allowed-but-{0}-is-{1}=seulement personnes physiques sont accepté, mais {0} est {1}
|
||||||
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
|
|
||||||
|
|
||||||
# office.coop-shares
|
# 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}
|
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}
|
||||||
|
|||||||
+253
@@ -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<HsAccountEntityBuilder> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-141
@@ -12,7 +12,6 @@ import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
|||||||
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
||||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||||
import org.hibernate.TransientObjectException;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
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.hs.office.relation.HsOfficeRelationType.REPRESENTATIVE;
|
||||||
import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt;
|
import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
|
||||||
|
|
||||||
@DataJpaTest
|
@DataJpaTest
|
||||||
@Tag("generalIntegrationTest")
|
@Tag("generalIntegrationTest")
|
||||||
@Import({ Context.class, JpaAttempt.class })
|
@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_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net";
|
||||||
private static final String SUPERUSER_FRAN_SUBJECT_NAME = "superuser-fran@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;
|
private HsOfficePersonRealRepository personRepo;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private HsProfileRepository profileRepository;
|
private HsAccountRepository accountRepository;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private HsProfileScopeRealRepository scopeRealRepo;
|
|
||||||
|
|
||||||
// fetched UUIDs from test-data
|
// fetched UUIDs from test-data
|
||||||
private RealSubjectEntity alexSubject;
|
private RealSubjectEntity alexSubject;
|
||||||
@@ -82,7 +77,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
@Test
|
@Test
|
||||||
public void historizationIsAvailable() {
|
public void historizationIsAvailable() {
|
||||||
// given
|
// given
|
||||||
final String nativeQuerySql = "select * from hs_accounts.profile_hv";
|
final String nativeQuerySql = "select * from hs_accounts.account_hv";
|
||||||
|
|
||||||
// when
|
// when
|
||||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||||
@@ -91,7 +86,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
assertThat(rowsBefore)
|
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);
|
.hasSize(0);
|
||||||
|
|
||||||
// and when
|
// and when
|
||||||
@@ -101,12 +96,12 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
assertThat(rowsAfter)
|
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);
|
.hasSize(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void representativeShouldFindOwnAndRepresentedProfileByCurrentSubject() {
|
void representativeShouldFindOwnAndRepresentedAccountByCurrentSubject() {
|
||||||
// given
|
// given
|
||||||
final var firstGmbHPerson = givenPerson("First GmbH");
|
final var firstGmbHPerson = givenPerson("First GmbH");
|
||||||
givenRelation(REPRESENTATIVE)
|
givenRelation(REPRESENTATIVE)
|
||||||
@@ -114,163 +109,76 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
.withHolder(drewPerson)
|
.withHolder(drewPerson)
|
||||||
.withContact("some test contact")
|
.withContact("some test contact")
|
||||||
.inDatabase();
|
.inDatabase();
|
||||||
givenProfile()
|
givenAccount()
|
||||||
.forSubject("first-gmbh")
|
.forSubject("first-gmbh")
|
||||||
.forPerson(firstGmbHPerson)
|
.forPerson(firstGmbHPerson)
|
||||||
.withEMailAddress("first-gmbh@example.com")
|
|
||||||
.inDatabase();
|
.inDatabase();
|
||||||
|
|
||||||
// when
|
// when
|
||||||
final var foundProfile = attempt(
|
final var foundAccount = attempt(
|
||||||
em, () -> {
|
em, () -> {
|
||||||
context(USER_DREW_SUBJECT_NAME);
|
context(USER_DREW_SUBJECT_NAME);
|
||||||
return profileRepository.findByCurrentSubject();
|
return accountRepository.findByCurrentSubject();
|
||||||
})
|
})
|
||||||
.assertNotNull().returnedValue();
|
.assertNotNull().returnedValue();
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assertThat(foundProfile).hasSize(2)
|
assertThat(foundAccount).hasSize(2)
|
||||||
.map(HsProfileEntity::getEmailAddress)
|
.map(e -> e.getSubject().getName())
|
||||||
.containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com");
|
.containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void globalAdminShouldFindOnlyOwnProfileByCurrentSubject() {
|
void globalAdminShouldFindOnlyOwnAccountByCurrentSubject() {
|
||||||
|
|
||||||
// when
|
// when
|
||||||
final var foundProfile = attempt(
|
final var foundAccount = attempt(
|
||||||
em, () -> {
|
em, () -> {
|
||||||
context(SUPERUSER_FRAN_SUBJECT_NAME);
|
context(SUPERUSER_FRAN_SUBJECT_NAME);
|
||||||
return profileRepository.findByCurrentSubject();
|
return accountRepository.findByCurrentSubject();
|
||||||
})
|
})
|
||||||
.assertNotNull().returnedValue();
|
.assertNotNull().returnedValue();
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assertThat(foundProfile).hasSize(1)
|
assertThat(foundAccount).hasSize(1)
|
||||||
.map(HsProfileEntity::getEmailAddress)
|
.map(e -> e.getSubject().getName())
|
||||||
.containsExactlyInAnyOrder("fran@example.com");
|
.containsExactlyInAnyOrder("fran@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldFindByUuidUsingTestData() {
|
void shouldFindByUuidUsingTestData() {
|
||||||
// when
|
// when
|
||||||
final var foundEntityOptional = profileRepository.findByUuid(alexSubject.getUuid());
|
final var foundEntityOptional = accountRepository.findByUuid(alexSubject.getUuid());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assertThat(foundEntityOptional).isPresent()
|
assertThat(foundEntityOptional).isPresent()
|
||||||
.map(HsProfileEntity::getEmailAddress).contains("alex@example.com");
|
.map(e -> e.getSubject().getName())
|
||||||
|
.contains("alex@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldSaveProfileWithExistingScope() {
|
void shouldSaveAccount() {
|
||||||
// given
|
// given
|
||||||
final var existingScope = scopeRealRepo.findByTypeAndQualifier("HSADMIN", "prod")
|
final var newAccount = HsAccountEntity.builder()
|
||||||
.orElseThrow();
|
|
||||||
final var newProfile = HsProfileEntity.builder()
|
|
||||||
.subject(testUserSubject)
|
.subject(testUserSubject)
|
||||||
.person(testUserPerson)
|
.person(testUserPerson)
|
||||||
.active(true)
|
|
||||||
.emailAddress("test-user@example.com")
|
|
||||||
.globalUid(2011)
|
.globalUid(2011)
|
||||||
.globalGid(2011)
|
.globalGid(2011)
|
||||||
.scopes(mutableSetOf(existingScope))
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// when
|
// when
|
||||||
toCleanup(profileRepository.save(newProfile));
|
toCleanup(accountRepository.save(newAccount));
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
// then
|
// then
|
||||||
final var foundEntityOptional = profileRepository.findByUuid(testUserSubject.getUuid());
|
final var foundEntityOptional = accountRepository.findByUuid(testUserSubject.getUuid());
|
||||||
assertThat(foundEntityOptional).isPresent();
|
assertThat(foundEntityOptional).isPresent();
|
||||||
final var foundEntity = foundEntityOptional.get();
|
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.getVersion()).isEqualTo(0); // Initial version
|
||||||
assertThat(foundEntity.getGlobalUid()).isEqualTo(2011);
|
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) {
|
private RealSubjectEntity fetchSubjectByName(final String name) {
|
||||||
final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name";
|
final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name";
|
||||||
final Query query = em.createQuery(jpql, RealSubjectEntity.class);
|
final Query query = em.createQuery(jpql, RealSubjectEntity.class);
|
||||||
@@ -315,8 +223,8 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
return new RelationBuilder(relationType);
|
return new RelationBuilder(relationType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProfileBuilder givenProfile() {
|
private AccountBuilder givenAccount() {
|
||||||
return new ProfileBuilder();
|
return new AccountBuilder();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class RelationBuilder {
|
private class RelationBuilder {
|
||||||
@@ -361,12 +269,11 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ProfileBuilder {
|
private class AccountBuilder {
|
||||||
private RealSubjectEntity subject;
|
private RealSubjectEntity subject;
|
||||||
private HsOfficePersonRealEntity person;
|
private HsOfficePersonRealEntity person;
|
||||||
private String emailAddress;
|
|
||||||
|
|
||||||
public ProfileBuilder forSubject(String subjectName) {
|
public AccountBuilder forSubject(String subjectName) {
|
||||||
// only the RbacSubject can be created
|
// only the RbacSubject can be created
|
||||||
val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder()
|
val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder()
|
||||||
.name(subjectName)
|
.name(subjectName)
|
||||||
@@ -376,36 +283,22 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProfileBuilder forPerson(HsOfficePersonRealEntity person) {
|
public AccountBuilder forPerson(HsOfficePersonRealEntity person) {
|
||||||
this.person = person;
|
this.person = person;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProfileBuilder withEMailAddress(String emailAddress) {
|
public HsAccountEntity inDatabase() {
|
||||||
this.emailAddress = emailAddress;
|
|
||||||
final var profile = HsProfileEntity.builder()
|
final var account = HsAccountEntity.builder()
|
||||||
.uuid(subject.getUuid())
|
.uuid(subject.getUuid())
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
|
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
|
||||||
.emailAddress(emailAddress)
|
|
||||||
.active(true)
|
|
||||||
.build();
|
.build();
|
||||||
return this;
|
em.persist(account);
|
||||||
}
|
toCleanup(account);
|
||||||
|
|
||||||
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.flush();
|
em.flush();
|
||||||
return profile;
|
return account;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-488
@@ -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<HsProfileEntityBuilder> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-167
@@ -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<String> 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<String> 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<HsProfileScopeRealEntity> initialScopeEntities = Set.of(initialScopeEntity1, initialScopeEntity2);
|
|
||||||
private List<ScopeResource> patchedScopeResources;
|
|
||||||
private final Set<HsProfileScopeRealEntity> 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<Property> 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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-210
@@ -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()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-167
@@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-181
@@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+53
@@ -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<AccountCanViewTheirOwnMemberships> {
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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<AccountCanViewTheirOwnPerson> {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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<AccountCanViewTheirOwnRelations> {
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+232
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends UseCase<?>> extends UseCase<T> {
|
||||||
|
|
||||||
|
protected final FakeLoginUser asLoginUser;
|
||||||
|
|
||||||
|
public BaseAccountUseCase(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
|
||||||
|
super(testSuite);
|
||||||
|
this.asLoginUser = asLoginUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T extends UseCase<?>> extends UseCase<T> {
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-26
@@ -9,42 +9,31 @@ import org.springframework.http.HttpStatus;
|
|||||||
import static io.restassured.http.ContentType.JSON;
|
import static io.restassured.http.ContentType.JSON;
|
||||||
import static org.springframework.http.HttpStatus.OK;
|
import static org.springframework.http.HttpStatus.OK;
|
||||||
|
|
||||||
public class CreateProfileForExistingPerson extends BaseProfileUseCase<CreateProfileForExistingPerson> {
|
public class CreateAccountForExistingPerson extends BaseAccountUseCase<CreateAccountForExistingPerson> {
|
||||||
|
|
||||||
public CreateProfileForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
|
public CreateAccountForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
|
||||||
super(testSuite, 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
|
@Override
|
||||||
protected HttpResponse run() {
|
protected HttpResponse run() {
|
||||||
|
|
||||||
obtain("Person: %{personGivenName} %{personFamilyName}", () ->
|
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),
|
.expecting(OK).expecting(JSON),
|
||||||
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
|
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
|
||||||
"In real situations we have more precise measures to find the related person."
|
"In real situations we have more precise measures to find the related person."
|
||||||
);
|
);
|
||||||
|
|
||||||
given("resolvedScopes",
|
return obtain("newAccount", () ->
|
||||||
fetchScopeResourcesByDescriptorPairs("scopes")
|
httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody("""
|
||||||
);
|
|
||||||
|
|
||||||
return obtain("newProfile", () ->
|
|
||||||
httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody("""
|
|
||||||
{
|
{
|
||||||
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
|
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
|
||||||
"nickname": ${nickname},
|
"subjectName": ${subjectName},
|
||||||
"emailAddress": ${emailAddress},
|
|
||||||
"smsNumber": ${smsNumber},
|
|
||||||
"password": ${password},
|
|
||||||
"totpSecrets": @{totpSecrets},
|
|
||||||
"phonePassword": ${phonePassword},
|
|
||||||
"globalUid": %{globalUid},
|
"globalUid": %{globalUid},
|
||||||
"globalGid": %{globalGid},
|
"globalGid": %{globalGid}
|
||||||
"active": %{active},
|
|
||||||
"scopes": @{resolvedScopes}
|
|
||||||
}
|
}
|
||||||
"""))
|
"""))
|
||||||
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
|
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
|
||||||
@@ -52,15 +41,14 @@ public class CreateProfileForExistingPerson extends BaseProfileUseCase<CreatePro
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void verify(final UseCase<CreateProfileForExistingPerson>.HttpResponse response) {
|
protected void verify(final UseCase<CreateAccountForExistingPerson>.HttpResponse response) {
|
||||||
verify(
|
verify(
|
||||||
"Verify the new Profile",
|
"Verify the new Account",
|
||||||
() -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}")
|
() -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}")
|
||||||
.expecting(OK).expecting(JSON),
|
.expecting(OK).expecting(JSON),
|
||||||
path("uuid").contains("%{newProfile}"),
|
path("uuid").contains("%{newAccount}"),
|
||||||
path("nickname").contains("%{nickname}"),
|
path("subjectName").contains("%{subjectName}"),
|
||||||
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"),
|
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
|
||||||
path("totpSecrets").contains("@{totpSecrets}")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-25
@@ -9,23 +9,19 @@ import org.springframework.http.HttpStatus;
|
|||||||
import static io.restassured.http.ContentType.JSON;
|
import static io.restassured.http.ContentType.JSON;
|
||||||
import static org.springframework.http.HttpStatus.OK;
|
import static org.springframework.http.HttpStatus.OK;
|
||||||
|
|
||||||
public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileForNewPerson> {
|
public class CreateAccountForNewPerson extends BaseAccountUseCase<CreateAccountForNewPerson> {
|
||||||
|
|
||||||
public CreateProfileForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
|
public CreateAccountForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
|
||||||
super(testSuite, 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
|
@Override
|
||||||
protected HttpResponse run() {
|
protected HttpResponse run() {
|
||||||
|
|
||||||
given("resolvedScopes",
|
return obtain("newAccount", () ->
|
||||||
fetchScopeResourcesByDescriptorPairs("scopes")
|
httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody("""
|
||||||
);
|
|
||||||
|
|
||||||
return obtain("newProfile", () ->
|
|
||||||
httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody("""
|
|
||||||
{
|
{
|
||||||
"person": {
|
"person": {
|
||||||
"personType": "NATURAL_PERSON",
|
"personType": "NATURAL_PERSON",
|
||||||
@@ -34,16 +30,9 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
|
|||||||
"givenName": ${personGivenName},
|
"givenName": ${personGivenName},
|
||||||
"familyName": ${personFamilyName}
|
"familyName": ${personFamilyName}
|
||||||
},
|
},
|
||||||
"nickname": ${nickname},
|
"subjectName": ${subjectName},
|
||||||
"emailAddress": ${emailAddress},
|
|
||||||
"smsNumber": ${smsNumber},
|
|
||||||
"password": ${password},
|
|
||||||
"totpSecrets": @{totpSecrets},
|
|
||||||
"phonePassword": ${phonePassword},
|
|
||||||
"globalUid": %{globalUid},
|
"globalUid": %{globalUid},
|
||||||
"globalGid": %{globalGid},
|
"globalGid": %{globalGid}
|
||||||
"active": %{active},
|
|
||||||
"scopes": @{resolvedScopes}
|
|
||||||
}
|
}
|
||||||
"""))
|
"""))
|
||||||
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
|
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
|
||||||
@@ -51,7 +40,7 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void verify(final UseCase<CreateProfileForNewPerson>.HttpResponse response) {
|
protected void verify(final UseCase<CreateAccountForNewPerson>.HttpResponse response) {
|
||||||
obtain("Person: %{personGivenName} %{personFamilyName}", () ->
|
obtain("Person: %{personGivenName} %{personFamilyName}", () ->
|
||||||
httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}")
|
httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}")
|
||||||
.expecting(OK).expecting(JSON),
|
.expecting(OK).expecting(JSON),
|
||||||
@@ -60,13 +49,12 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
|
|||||||
);
|
);
|
||||||
|
|
||||||
verify(
|
verify(
|
||||||
"Verify the new Profile",
|
"Verify the new Account",
|
||||||
() -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}")
|
() -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}")
|
||||||
.expecting(OK).expecting(JSON),
|
.expecting(OK).expecting(JSON),
|
||||||
path("uuid").contains("%{newProfile}"),
|
path("uuid").contains("%{newAccount}"),
|
||||||
path("nickname").contains("%{nickname}"),
|
path("subjectName").contains("%{subjectName}"),
|
||||||
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"),
|
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
|
||||||
path("totpSecrets").contains("@{totpSecrets}")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase;
|
|||||||
|
|
||||||
import static io.restassured.http.ContentType.JSON;
|
import static io.restassured.http.ContentType.JSON;
|
||||||
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent;
|
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.ScenarioTest.resolve;
|
||||||
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
|
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -33,10 +33,7 @@ public class CurrentLoginUser extends UseCase<CurrentLoginUser> {
|
|||||||
|
|
||||||
return obtain(
|
return obtain(
|
||||||
"Current Login User", () ->
|
"Current Login User", () ->
|
||||||
httpGet(
|
httpGet( asSubject("%{subjectName}"), "/api/hs/accounts/current")
|
||||||
"/api/hs/accounts/current", req -> req
|
|
||||||
.header("Authorization", bearerTemplate("%{subjectName}"))
|
|
||||||
)
|
|
||||||
.expecting(OK).expecting(JSON).expectObject()
|
.expecting(OK).expecting(JSON).expectObject()
|
||||||
.extractValue("subject.name", "returnedSubjectName")
|
.extractValue("subject.name", "returnedSubjectName")
|
||||||
.extractValue("person.givenName", "returnedGivenName")
|
.extractValue("person.givenName", "returnedGivenName")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static io.restassured.http.ContentType.JSON;
|
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.resolve;
|
||||||
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray;
|
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray;
|
||||||
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
|
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
|
||||||
@@ -26,9 +26,8 @@ public class FetchRbacContext extends UseCase<FetchRbacContext> {
|
|||||||
protected HttpResponse run() {
|
protected HttpResponse run() {
|
||||||
return obtain(
|
return obtain(
|
||||||
"RBAC Context", () ->
|
"RBAC Context", () ->
|
||||||
httpGet(
|
httpGet( asSubject("%{subjectName}"),
|
||||||
"/api/rbac/context", req -> req
|
"/api/rbac/context", req -> req
|
||||||
.header("Authorization", bearerTemplate("%{subjectName}"))
|
|
||||||
.header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS))
|
.header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS))
|
||||||
)
|
)
|
||||||
.expecting(OK).expecting(JSON).expectObject()
|
.expecting(OK).expecting(JSON).expectObject()
|
||||||
|
|||||||
-186
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<UpdateProfile> {
|
|
||||||
|
|
||||||
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<UpdateProfile>.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}")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-2
@@ -360,8 +360,9 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() {
|
void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() {
|
||||||
domainSetupFor("sub.sub.example.org").notRegistered()
|
Dns.fakeResultForDomain("unreg.example.org", DOMAIN_NOT_REGISTERED);
|
||||||
.isRejectedWithCauseDomainNameNotFound("sub.example.org");
|
domainSetupFor("sub.unreg.example.org").notRegistered()
|
||||||
|
.isRejectedWithCauseDomainNameNotFound("unreg.example.org");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+15
@@ -60,6 +60,21 @@ class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup
|
|||||||
@Test
|
@Test
|
||||||
void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() {
|
void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void globalAdmin_withoutAssumedRoles_canViewAllPersons_byNameAndPersonType() {
|
||||||
|
|
||||||
RestAssured // @formatter:off
|
RestAssured // @formatter:off
|
||||||
.given()
|
.given()
|
||||||
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
|
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
package net.hostsharing.hsadminng.hs.scenarios;
|
package net.hostsharing.hsadminng.hs.scenarios;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.val;
|
||||||
import net.hostsharing.hsadminng.config.JwtFakeBearer;
|
import net.hostsharing.hsadminng.config.JwtFakeBearer;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
|
||||||
|
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class FakeLoginUser {
|
public class FakeLoginUser {
|
||||||
|
|
||||||
final static String GLOBAL_AGENT = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
|
final static String GLOBAL_AGENT = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
public static FakeLoginUser as(final String name) {
|
public static FakeLoginUser asSubject(final String name) {
|
||||||
return new FakeLoginUser(name);
|
val resolvedName = ScenarioTest.resolve( name, DROP_COMMENTS);
|
||||||
|
return new FakeLoginUser(resolvedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static FakeLoginUser asGlobalAgent() {
|
public static FakeLoginUser asGlobalAgent() {
|
||||||
@@ -21,4 +24,8 @@ public class FakeLoginUser {
|
|||||||
public String bearer() {
|
public String bearer() {
|
||||||
return JwtFakeBearer.bearer(name);
|
return JwtFakeBearer.bearer(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("<br>");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -274,17 +274,11 @@ public abstract class ScenarioTest extends ContextBasedTest {
|
|||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
return (T) new BigDecimal(resolvedValue);
|
return (T) new BigDecimal(resolvedValue);
|
||||||
}
|
}
|
||||||
|
if (valueType == Integer.class) {
|
||||||
|
//noinspection unchecked
|
||||||
|
return (T) Integer.valueOf(resolvedValue);
|
||||||
|
}
|
||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
return (T) resolvedValue;
|
return (T) resolvedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> T getTypedVariable(final String varName, final Class<T> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
public class TestReport {
|
public class TestReport {
|
||||||
|
|
||||||
public static final File BUILD_DOC_SCENARIOS = new File("build/doc/scenarios");
|
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 File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md");
|
||||||
private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build();
|
private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build();
|
||||||
@@ -96,7 +96,7 @@ public class TestReport {
|
|||||||
public void close() {
|
public void close() {
|
||||||
if (markdownReport != null) {
|
if (markdownReport != null) {
|
||||||
printPara("---");
|
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();
|
markdownReport.close();
|
||||||
System.out.println("SCENARIO REPORT: " + asClickableLink(markdownReportFile));
|
System.out.println("SCENARIO REPORT: " + asClickableLink(markdownReportFile));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import java.util.function.Supplier;
|
|||||||
|
|
||||||
import static java.net.URLEncoder.encode;
|
import static java.net.URLEncoder.encode;
|
||||||
import static java.util.stream.Collectors.joining;
|
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.DROP_COMMENTS;
|
||||||
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS;
|
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS;
|
||||||
import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached;
|
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<T extends UseCase<?>> {
|
public abstract class UseCase<T extends UseCase<?>> {
|
||||||
|
|
||||||
private static final HttpClient client = HttpClient.newHttpClient();
|
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 ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
protected final ScenarioTest testSuite;
|
protected final ScenarioTest testSuite;
|
||||||
@@ -54,6 +55,7 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
|
private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
|
||||||
private final String resultAlias;
|
private final String resultAlias;
|
||||||
private final Map<String, Object> givenProperties = new LinkedHashMap<>();
|
private final Map<String, Object> givenProperties = new LinkedHashMap<>();
|
||||||
|
private final Map<String, Object> expectedProperties = new LinkedHashMap<>();
|
||||||
|
|
||||||
private String nextTitle; // just temporary to override resultAlias for sub-use-cases
|
private String nextTitle; // just temporary to override resultAlias for sub-use-cases
|
||||||
private String introduction;
|
private String introduction;
|
||||||
@@ -79,13 +81,10 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
if (introduction != null) {
|
if (introduction != null) {
|
||||||
testReport.printPara(introduction);
|
testReport.printPara(introduction);
|
||||||
}
|
}
|
||||||
testReport.printPara("### Given Properties");
|
testReport.printPara("### Properties");
|
||||||
testReport.printLine("""
|
renderProperties("Given", givenProperties);
|
||||||
| name | value |
|
renderProperties("Expected", expectedProperties);
|
||||||
|------|-------|""");
|
|
||||||
givenProperties.forEach((key, value) ->
|
|
||||||
testReport.printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
|
|
||||||
testReport.printLine("");
|
|
||||||
testReport.silent(() ->
|
testReport.silent(() ->
|
||||||
requirements.forEach((alias, factory) -> {
|
requirements.forEach((alias, factory) -> {
|
||||||
final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS);
|
final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS);
|
||||||
@@ -106,6 +105,21 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void renderProperties(final String title, final Map<String, Object> 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
|
// this method is called by the test framework, override, but do not call from subclass
|
||||||
protected HttpResponse run(final HttpStatus expectedStatus) {
|
protected HttpResponse run(final HttpStatus expectedStatus) {
|
||||||
assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK);
|
assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK);
|
||||||
@@ -129,6 +143,15 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
return this;
|
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<T> 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) {
|
public final JsonTemplate usingJsonBody(final String jsonTemplate) {
|
||||||
return new JsonTemplate(jsonTemplate);
|
return new JsonTemplate(jsonTemplate);
|
||||||
}
|
}
|
||||||
@@ -166,23 +189,25 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public final HttpResponse httpGet(
|
public final HttpResponse httpGet(
|
||||||
|
final FakeLoginUser loginUser,
|
||||||
final String uriPathWithPlaceholder,
|
final String uriPathWithPlaceholder,
|
||||||
final Function<HttpRequest.Builder, HttpRequest.Builder> requestCustomizer) {
|
final Function<HttpRequest.Builder, HttpRequest.Builder> requestCustomizer) {
|
||||||
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS);
|
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS);
|
||||||
final var requestBuilder = HttpRequest.newBuilder()
|
final var requestBuilder = HttpRequest.newBuilder()
|
||||||
.GET()
|
.GET()
|
||||||
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
.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 customizedRequestBuilder = requestCustomizer.apply(requestBuilder);
|
||||||
final var request = customizedRequestBuilder.build();
|
final var request = customizedRequestBuilder.build();
|
||||||
final var response = client.send(request, BodyHandlers.ofString());
|
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(
|
||||||
public final HttpResponse httpGet(final FakeLoginUser loginUser, final String uriPathWithPlaceholders) {
|
final FakeLoginUser loginUser,
|
||||||
return httpGet(uriPathWithPlaceholders,
|
final String uriPathWithPlaceholder) {
|
||||||
req -> req.header("Authorization", loginUser.bearer()));
|
return httpGet(loginUser, uriPathWithPlaceholder, requestBuilder -> requestBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
@@ -196,10 +221,11 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", loginUser.bearer())
|
.header("Authorization", loginUser.bearer())
|
||||||
|
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
|
||||||
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
||||||
.build();
|
.build();
|
||||||
final var response = client.send(request, BodyHandlers.ofString());
|
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
|
@SneakyThrows
|
||||||
@@ -214,10 +240,11 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", loginUser.bearer())
|
.header("Authorization", loginUser.bearer())
|
||||||
|
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
|
||||||
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
||||||
.build();
|
.build();
|
||||||
final var response = client.send(request, BodyHandlers.ofString());
|
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
|
@SneakyThrows
|
||||||
@@ -228,10 +255,11 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", loginUser.bearer())
|
.header("Authorization", loginUser.bearer())
|
||||||
|
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
|
||||||
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
|
||||||
.build();
|
.build();
|
||||||
final var response = client.send(request, BodyHandlers.ofString());
|
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) {
|
protected PathAssertion path(final String path) {
|
||||||
@@ -289,9 +317,11 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
|
|
||||||
public final class HttpResponse {
|
public final class HttpResponse {
|
||||||
|
|
||||||
|
private static final String AUTH_HEADER_KEY = "Authorization";
|
||||||
private final HttpMethod httpMethod;
|
private final HttpMethod httpMethod;
|
||||||
private final String uri;
|
private final String uri;
|
||||||
private final String requestBody;
|
private final String requestBody;
|
||||||
|
private final @Nullable String authUserName;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
private final java.net.http.HttpResponse<String> response;
|
private final java.net.http.HttpResponse<String> response;
|
||||||
@@ -310,12 +340,14 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
final HttpMethod httpMethod,
|
final HttpMethod httpMethod,
|
||||||
final String uri,
|
final String uri,
|
||||||
final String requestBody,
|
final String requestBody,
|
||||||
final java.net.http.HttpResponse<String> response
|
final java.net.http.HttpResponse<String> response,
|
||||||
|
final @Nullable FakeLoginUser loginUser
|
||||||
) {
|
) {
|
||||||
this.httpMethod = httpMethod;
|
this.httpMethod = httpMethod;
|
||||||
this.uri = uri;
|
this.uri = uri;
|
||||||
this.requestBody = requestBody;
|
this.requestBody = requestBody;
|
||||||
this.response = response;
|
this.response = response;
|
||||||
|
this.authUserName = loginUser == null ? null : loginUser.name();
|
||||||
this.status = HttpStatus.valueOf(response.statusCode());
|
this.status = HttpStatus.valueOf(response.statusCode());
|
||||||
if (this.status == HttpStatus.CREATED) {
|
if (this.status == HttpStatus.CREATED) {
|
||||||
final var location = response.headers().firstValue("Location").orElseThrow();
|
final var location = response.headers().firstValue("Location").orElseThrow();
|
||||||
@@ -386,6 +418,10 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBody() {
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public <V> V getFromBody(final String path) {
|
public <V> V getFromBody(final String path) {
|
||||||
final var body = response.body();
|
final var body = response.body();
|
||||||
@@ -431,7 +467,8 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
|
|
||||||
// the request
|
// the request
|
||||||
testReport.printLine("```");
|
testReport.printLine("```");
|
||||||
testReport.printLine(httpMethod.name() + " " + uri);
|
testReport.printLine(httpMethod.name() + " '" + uri + "'");
|
||||||
|
printRequestHeaders();
|
||||||
testReport.printJson(requestBody);
|
testReport.printJson(requestBody);
|
||||||
|
|
||||||
// the response
|
// the response
|
||||||
@@ -446,6 +483,33 @@ public abstract class UseCase<T extends UseCase<?>> {
|
|||||||
return this;
|
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<String, List<String>> entry) {
|
||||||
|
if ("X-Fake-Authorization".equalsIgnoreCase(entry.getKey())) {
|
||||||
|
return fakeAuthorizationValue();
|
||||||
|
}
|
||||||
|
return String.join(", ", entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fakeAuthorizationValue() {
|
||||||
|
return authUserName == null ? "" : "Bearer <" + authUserName + ">";
|
||||||
|
}
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
private void optionallyReportRequestAndResponse() {
|
private void optionallyReportRequestAndResponse() {
|
||||||
if (!reportGenerated) {
|
if (!reportGenerated) {
|
||||||
|
|||||||
@@ -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<NonStringifyableTestEntity> {
|
||||||
|
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<StringifyableTestEntity>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,11 +45,13 @@ public class JsonMatcher extends BaseMatcher<CharSequence> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean matches(final Object actual) {
|
public boolean matches(final Object actual) {
|
||||||
if (actual == null || actual.getClass().isAssignableFrom(CharSequence.class)) {
|
if (actual == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
JSONAssert.assertEquals(expectedJson, actualJson, compareMode);
|
||||||
return true;
|
return true;
|
||||||
} catch (final JSONException | JsonProcessingException e) {
|
} catch (final JSONException | JsonProcessingException e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user