diff --git a/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java index dcd6af6e..087b76f2 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java +++ b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.config; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.openapitools.jackson.nullable.JsonNullableModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,6 +14,6 @@ public class JsonObjectMapperConfiguration { @Primary public Jackson2ObjectMapperBuilder customObjectMapper() { return new Jackson2ObjectMapperBuilder() - .modules(new JsonNullableModule()); + .modules(new JsonNullableModule(), new JavaTimeModule()); } } diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index 78bd83ea..3adeafba 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -34,6 +34,13 @@ public class RestResponseEntityExceptionHandler return errorResponse(request, httpStatus(message).orElse(HttpStatus.FORBIDDEN), message); } + @ExceptionHandler(Throwable.class) + protected ResponseEntity handleOtherExceptions( + final RuntimeException exc, final WebRequest request) { + final var message = firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage()); + return errorResponse(request, httpStatus(message).orElse(HttpStatus.FORBIDDEN), message); + } + private Optional httpStatus(final String message) { if (message.startsWith("ERROR: [")) { for (HttpStatus status : HttpStatus.values()) { @@ -48,10 +55,10 @@ public class RestResponseEntityExceptionHandler private static ResponseEntity errorResponse( final WebRequest request, - final HttpStatus conflict, + final HttpStatus httpStatus, final String message) { return new ResponseEntity<>( - new CustomErrorResponse(request.getContextPath(), conflict, message), conflict); + new CustomErrorResponse(request.getContextPath(), httpStatus, message), httpStatus); } private String firstLine(final String message) { diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepository.java index 0a182b73..f44b4ffd 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepository.java @@ -17,4 +17,5 @@ public interface CustomerRepository extends Repository { CustomerEntity save(final CustomerEntity entity); + long count(); } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java index b05ef93f..f92aa9e0 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java @@ -7,10 +7,14 @@ import net.hostsharing.hsadminng.generated.api.v1.model.RbacUserResource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import javax.persistence.EntityManager; import javax.transaction.Transactional; import java.util.List; +import java.util.UUID; +import static net.hostsharing.hsadminng.Mapper.map; import static net.hostsharing.hsadminng.Mapper.mapList; @RestController @@ -19,15 +23,36 @@ public class RbacUserController implements RbacusersApi { @Autowired private Context context; + @Autowired + private EntityManager em; + @Autowired private RbacUserRepository rbacUserRepository; + @Override + @Transactional + public ResponseEntity createUser( + @RequestBody final RbacUserResource body + ) { + if (body.getUuid() == null) { + body.setUuid(UUID.randomUUID()); + } + final var saved = map(body, RbacUserEntity.class); + rbacUserRepository.create(saved); + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/rbac-users/{id}") + .buildAndExpand(saved.getUuid()) + .toUri(); + return ResponseEntity.created(uri).body(map(saved, RbacUserResource.class)); + } + @Override @Transactional public ResponseEntity> listUsers( - @RequestHeader(name = "current-user") String currentUserName, - @RequestHeader(name = "assumed-roles", required = false) String assumedRoles, - @RequestParam(name="name", required = false) String userName + @RequestHeader(name = "current-user") final String currentUserName, + @RequestHeader(name = "assumed-roles", required = false) final String assumedRoles, + @RequestParam(name = "name", required = false) final String userName ) { context.setCurrentUser(currentUserName); if (assumedRoles != null && !assumedRoles.isBlank()) { @@ -39,9 +64,9 @@ public class RbacUserController implements RbacusersApi { @Override @Transactional public ResponseEntity> listUserPermissions( - @RequestHeader(name = "current-user") String currentUserName, - @RequestHeader(name = "assumed-roles", required = false) String assumedRoles, - @PathVariable(name= "userName") String userName + @RequestHeader(name = "current-user") final String currentUserName, + @RequestHeader(name = "assumed-roles", required = false) final String assumedRoles, + @PathVariable(name = "userName") final String userName ) { context.setCurrentUser(currentUserName); if (assumedRoles != null && !assumedRoles.isBlank()) { diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java index 51e7af86..c43c611e 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java @@ -1,16 +1,41 @@ package net.hostsharing.hsadminng.rbac.rbacuser; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface RbacUserRepository extends Repository { - @Query("SELECT u FROM RbacUserEntity u WHERE :userName is null or u.name like concat(:userName, '%')") - List findByOptionalNameLike(final String userName); + @Query(""" + select u from RbacUserEntity u + where :userName is null or u.name like concat(:userName, '%') + order by u.name + """) + List findByOptionalNameLike(String userName); - @Query(value = "SELECT * FROM grantedPermissions(:userName)", nativeQuery = true) + RbacUserEntity findByUuid(UUID uuid); + + @Query(value = "select * from grantedPermissions(:userName)", nativeQuery = true) List findPermissionsOfUser(String userName); + + /* + Can't use save/saveAndFlush from SpringData because the uuid is not generated on the entity level, + but explicitly, and then SpringData check's if it exists using an SQL SELECT. + And SQL SELECT needs a currentUser which we don't yet have in the case of self registration. + */ + @Modifying + @Query(value = "insert into RBacUser_RV (uuid, name) values( :#{#newUser.uuid}, :#{#newUser.name})", nativeQuery = true) + void insert(@Param("newUser") final RbacUserEntity newUser); + + default RbacUserEntity create(final RbacUserEntity rbacUserEntity) { + if (rbacUserEntity.getUuid() == null) { + rbacUserEntity.setUuid(UUID.randomUUID()); + } + insert(rbacUserEntity); + return rbacUserEntity; + } } diff --git a/src/main/resources/api-definition/error-responses.yaml b/src/main/resources/api-definition/error-responses.yaml index 8d080237..83ca3dfb 100644 --- a/src/main/resources/api-definition/error-responses.yaml +++ b/src/main/resources/api-definition/error-responses.yaml @@ -14,7 +14,13 @@ components: schema: $ref: '#/components/schemas/Error' Forbidden: - description: The current user or none of the assumed or roles is granted access to the . + description: The current user or none of the assumed or roles is granted access to the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: The request could not be completed due to a conflict with the current state of the target resource. content: application/json: schema: diff --git a/src/main/resources/api-definition/rbac-users.yaml b/src/main/resources/api-definition/rbac-users.yaml index f8c23289..23ac4e7a 100644 --- a/src/main/resources/api-definition/rbac-users.yaml +++ b/src/main/resources/api-definition/rbac-users.yaml @@ -21,19 +21,28 @@ get: items: $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' "401": - description: if the 'current-user' cannot be identified - content: - 'application/json': - schema: - type: array - items: - $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: './api-definition/error-responses.yaml#/components/responses/Unauthorized' "403": - description: if the 'current-user' is not allowed to assume any of the roles - from 'assumed-roles' + $ref: './api-definition/error-responses.yaml#/components/responses/Forbidden' + +post: + tags: + - rbacusers + description: Create a new RBAC user. + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' + responses: + "201": + description: Created content: 'application/json': schema: - type: array - items: - $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' + $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' + "409": + $ref: './api-definition/error-responses.yaml#/components/responses/Conflict' + diff --git a/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql b/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql index 18a68730..001237a7 100644 --- a/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql +++ b/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql @@ -59,6 +59,22 @@ begin end; $$; +create or replace function createRbacUser(refUuid uuid, userName varchar) + returns uuid + called on null input + language plpgsql as $$ +begin + insert + into RbacReference as r (uuid, type) + values ( coalesce(refUuid, uuid_generate_v4()), 'RbacUser') + returning r.uuid into refUuid; + insert + into RbacUser (uuid, name) + values (refUuid, userName); + return refUuid; +end; +$$; + create or replace function findRbacUserId(userName varchar) returns uuid returns null on null input diff --git a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql b/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql index 241f1b09..415dcc89 100644 --- a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql +++ b/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql @@ -9,11 +9,17 @@ */ drop view if exists rbacrole_rv; create or replace view rbacrole_rv as -select DISTINCT r.*, o.objectTable, - findIdNameByObjectUuid(o.objectTable, o.uuid) as objectIdName - from rbacrole as r - join rbacobject as o on o.uuid=r.objectuuid - where isGranted(currentSubjectIds(), r.uuid); +select * + -- @formatter:off + from ( + select r.*, o.objectTable, + findIdNameByObjectUuid(o.objectTable, o.uuid) as objectIdName + from rbacrole as r + join rbacobject as o on o.uuid = r.objectuuid + where isGranted(currentSubjectIds(), r.uuid) + ) as unordered + -- @formatter:on + order by objectIdName; grant all privileges on rbacrole_rv to restricted; --// @@ -27,13 +33,58 @@ grant all privileges on rbacrole_rv to restricted; */ drop view if exists RbacUser_rv; create or replace view RbacUser_rv as -select u.* - from RbacUser as u - join RbacGrants as g on g.ascendantuuid = u.uuid - join rbacrole_rv as r on r.uuid = g.descendantuuid; + select distinct * + -- @formatter:off + from ( + select usersInRolesOfCurrentUser.* + from RbacUser as usersInRolesOfCurrentUser + join RbacGrants as g on g.ascendantuuid = usersInRolesOfCurrentUser.uuid + join rbacrole_rv as r on r.uuid = g.descendantuuid + union + select users.* + from RbacUser as users + where cardinality(assumedRoles()) = 0 and currentUserId() = users.uuid + ) as unordered + -- @formatter:on + order by unordered.name; grant all privileges on RbacUser_rv to restricted; --// +-- ============================================================================ +--changeset rbac-views-USER-RV-INSERT-TRIGGER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Instead of insert trigger function for RbacUser_rv. + */ +create or replace function insertRbacUser() + returns trigger + language plpgsql as $$ +declare + refUuid uuid; + newUser RbacUser; +begin + insert + into RbacReference as r (uuid, type) + values( new.uuid, 'RbacUser') + returning r.uuid into refUuid; + insert + into RbacUser (uuid, name) + values (refUuid, new.name) + returning * into newUser; + return newUser; +end; +$$; + +/* + Creates an instead of insert trigger for the RbacUser_rv view. + */ +create trigger insertRbacUser_Trigger + instead of insert + on RbacUser_rv + for each row +execute function insertRbacUser(); + -- ============================================================================ --changeset rbac-views-OWN-GRANTED-PERMISSIONS-VIEW:1 endDelimiter:--// diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java index e5742b17..d48f03e8 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java @@ -36,19 +36,21 @@ class CustomerRepositoryIntegrationTest { public void hostsharingAdmin_withoutAssumedRole_canCreateNewCustomer() { // given currentUser("mike@hostsharing.net"); + final var count = customerRepository.count(); // when - final var attempt = attempt(em, () -> { + final var result = attempt(em, () -> { final var newCustomer = new CustomerEntity( UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com"); return customerRepository.save(newCustomer); }); // then - assertThat(attempt.wasSuccessful()).isTrue(); - assertThat(attempt.returnedResult()).isNotNull().extracting(CustomerEntity::getUuid).isNotNull(); - assertThatCustomerIsPersisted(attempt.returnedResult()); + assertThat(result.wasSuccessful()).isTrue(); + assertThat(result.returnedValue()).isNotNull().extracting(CustomerEntity::getUuid).isNotNull(); + assertThatCustomerIsPersisted(result.returnedValue()); + assertThat(customerRepository.count()).isEqualTo(count + 1); } @Test @@ -58,14 +60,14 @@ class CustomerRepositoryIntegrationTest { assumedRoles("customer#aaa.admin"); // when - final var attempt = attempt(em, () -> { + final var result = attempt(em, () -> { final var newCustomer = new CustomerEntity( UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com"); return customerRepository.save(newCustomer); }); // then - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( PersistenceException.class, "add-customer not permitted for customer#aaa.admin"); } @@ -76,14 +78,14 @@ class CustomerRepositoryIntegrationTest { currentUser("admin@aaa.example.com"); // when - final var attempt = attempt(em, () -> { + final var result = attempt(em, () -> { final var newCustomer = new CustomerEntity( UUID.randomUUID(), "yyy", 90002, "admin@yyy.example.com"); return customerRepository.save(newCustomer); }); // then - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( PersistenceException.class, "add-customer not permitted for admin@aaa.example.com"); @@ -152,12 +154,12 @@ class CustomerRepositoryIntegrationTest { assumedRoles("package#aab00.admin"); // when - final var attempt = attempt( + final var result = attempt( em, () -> customerRepository.findCustomerByOptionalPrefixLike(null)); // then - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin"); } @@ -166,11 +168,11 @@ class CustomerRepositoryIntegrationTest { void unknownUser_withoutAssumedRole_cannotViewAnyCustomers() { currentUser("unknown@example.org"); - final var attempt = attempt( + final var result = attempt( em, () -> customerRepository.findCustomerByOptionalPrefixLike(null)); - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "hsadminng.currentUser defined as unknown@example.org, but does not exists"); } @@ -181,11 +183,11 @@ class CustomerRepositoryIntegrationTest { currentUser("unknown@example.org"); assumedRoles("customer#aaa.admin"); - final var attempt = attempt( + final var result = attempt( em, () -> customerRepository.findCustomerByOptionalPrefixLike(null)); - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "hsadminng.currentUser defined as unknown@example.org, but does not exists"); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java index cd0fef2a..5fb315f9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java @@ -85,12 +85,12 @@ class PackageRepositoryIntegrationTest { assumedRoles("package#aab00.admin"); // when - final var attempt = attempt( + final var result = attempt( em, () -> packageRepository.findAllByOptionalNameLike(null)); // then - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin"); } @@ -99,11 +99,11 @@ class PackageRepositoryIntegrationTest { void unknownUser_withoutAssumedRole_cannotViewAnyPackages() { currentUser("unknown@example.org"); - final var attempt = attempt( + final var result = attempt( em, () -> packageRepository.findAllByOptionalNameLike(null)); - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "hsadminng.currentUser defined as unknown@example.org, but does not exists"); } @@ -114,11 +114,11 @@ class PackageRepositoryIntegrationTest { currentUser("unknown@example.org"); assumedRoles("customer#aaa.admin"); - final var attempt = attempt( + final var result = attempt( em, () -> packageRepository.findAllByOptionalNameLike(null)); - attempt.assertExceptionWithRootCauseMessage( + result.assertExceptionWithRootCauseMessage( JpaSystemException.class, "hsadminng.currentUser defined as unknown@example.org, but does not exists"); } @@ -141,7 +141,6 @@ class PackageRepositoryIntegrationTest { .isEmpty(); } - void exactlyThesePackagesAreReturned(final List actualResult, final String... packageNames) { assertThat(actualResult) .extracting(PackageEntity::getName) diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java new file mode 100644 index 00000000..51ae2861 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java @@ -0,0 +1,157 @@ +package net.hostsharing.hsadminng.rbac.rbacuser; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.context.Context; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import javax.persistence.EntityManager; +import javax.transaction.Transactional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = HsadminNgApplication.class +) +@Transactional +class RbacUserControllerAcceptanceTest { + + @LocalServerPort + private Integer port; + + @Autowired + EntityManager em; + + @Autowired + Context context; + + @Autowired + RbacUserRepository rbacUserRepository; + + @Nested + class ApiRbacUsersGet { + + @Test + void hostsharingAdmin_withoutAssumedRole_canViewAllUsers() { + + // @formatter:off + RestAssured + .given() + .header("current-user", "mike@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/rbac-users") + .then().assertThat() + .statusCode(200) + .contentType("application/json") + .body("[0].name", is("aaa00@aaa.example.com")) + .body("[1].name", is("aaa01@aaa.example.com")) + .body("[2].name", is("aaa02@aaa.example.com")) + .body("size()", is(14)); + // @formatter:on + } + + @Test + void hostsharingAdmin_withoutAssumedRole_canViewAllUsersByName() { + + // @formatter:off + RestAssured + .given() + .header("current-user", "mike@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/rbac-users?name=aac") + .then().assertThat() + .statusCode(200) + .contentType("application/json") + .body("[0].name", is("aac00@aac.example.com")) + .body("[1].name", is("aac01@aac.example.com")) + .body("[2].name", is("aac02@aac.example.com")) + .body("size()", is(3)); + // @formatter:on + } + + @Test + void customerAdmin_withoutAssumedRole_canViewUsersInItsRealm() { + + // @formatter:off + RestAssured + .given() + .header("current-user", "admin@aab.example.com") + .port(port) + .when() + .get("http://localhost/api/rbac-users") + .then().assertThat() + .statusCode(200) + .contentType("application/json") + .body("[0].name", is("aab00@aab.example.com")) + .body("[1].name", is("aab01@aab.example.com")) + .body("[2].name", is("aab02@aab.example.com")) + .body("[3].name", is("admin@aab.example.com")) + .body("size()", is(4)); + // @formatter:on + } + + @Test + void packetAdmin_withoutAssumedRole_canViewAllUsersOfItsPackage() { + + // @formatter:off + RestAssured + .given() + .header("current-user", "aaa01@aaa.example.com") + .port(port) + .when() + .get("http://localhost/api/rbac-users") + .then().assertThat() + .statusCode(200) + .contentType("application/json") + .body("[0].name", is("aaa01@aaa.example.com")) + .body("size()", is(1)); + // @formatter:on + } + } + + @Nested + class ApiRbacUsersPost { + + @Test + void anybody_canCreateANewUser() { + + // @formatter:off + final var location = RestAssured + .given() + .contentType(ContentType.JSON) + .body(""" + { + "name": "new-user@example.com" + } + """) + .port(port) + .when() + .post("http://localhost/api/rbac-users") + .then().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("name", is("new-user@example.com")) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); + // @formatter:on + + // finally, the user can view its own record + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + context.setCurrentUser("new-user@example.com"); + assertThat(rbacUserRepository.findByUuid(newUserUuid)) + .extracting(RbacUserEntity::getName).isEqualTo("new-user@example.com"); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java deleted file mode 100644 index 10641567..00000000 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerRestTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package net.hostsharing.hsadminng.rbac.rbacuser; - -import net.hostsharing.hsadminng.context.Context; -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.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -import java.util.List; - -import static net.hostsharing.hsadminng.rbac.rbacrole.TestRbacRole.*; -import static net.hostsharing.hsadminng.rbac.rbacuser.TestRbacUser.userAaa; -import static net.hostsharing.hsadminng.rbac.rbacuser.TestRbacUser.userBbb; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(RbacUserController.class) -class RbacUserControllerRestTest { - - @Autowired - MockMvc mockMvc; - @MockBean - Context contextMock; - @MockBean - RbacUserRepository rbacUserRepository; - - @Test - void willListAllUsers() throws Exception { - - // given - when(rbacUserRepository.findByOptionalNameLike(null)).thenReturn( - List.of(userAaa, userBbb)); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/rbac-users") - .header("current-user", "mike@hostsharing.net") - .accept(MediaType.APPLICATION_JSON)) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) - .andExpect(jsonPath("$[0].name", is(userAaa.getName()))) - .andExpect(jsonPath("$[1].name", is(userBbb.getName()))); - } - - @Test - void willListUsersByName() throws Exception { - - // given - when(rbacUserRepository.findByOptionalNameLike("admin@aaa")).thenReturn( - List.of(userAaa)); - - // when - mockMvc.perform(MockMvcRequestBuilders - .get("/api/rbac-users") - .param("name", "admin@aaa") - .header("current-user", "mike@hostsharing.net") - .accept(MediaType.APPLICATION_JSON)) - - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))) - .andExpect(jsonPath("$[0].name", is(userAaa.getName()))); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java index 96df4196..3f508820 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java @@ -2,21 +2,26 @@ package net.hostsharing.hsadminng.rbac.rbacuser; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.test.Array; +import net.hostsharing.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.ComponentScan; import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.test.annotation.Commit; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityManager; import java.util.List; +import java.util.UUID; import static net.hostsharing.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@ComponentScan(basePackageClasses = { Context.class, RbacUserRepository.class }) +@ComponentScan(basePackageClasses = { RbacUserRepository.class, Context.class, JpaAttempt.class }) class RbacUserRepositoryIntegrationTest { @Autowired @@ -25,8 +30,59 @@ class RbacUserRepositoryIntegrationTest { @Autowired RbacUserRepository rbacUserRepository; + @Autowired + JpaAttempt jpaAttempt; + @Autowired EntityManager em; + @Nested + class CreateUser { + + @Test + public void anyoneCanCreateTheirOwnUser() { + // given + final var givenNewUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; + + // when + final var result = rbacUserRepository.create( + new RbacUserEntity(null, givenNewUserName)); + + // then the persisted user is returned + assertThat(result).isNotNull().extracting(RbacUserEntity::getName).isEqualTo(givenNewUserName); + + // and the new user entity can be fetched by the user itself + currentUser(givenNewUserName); + assertThat(em.find(RbacUserEntity.class, result.getUuid())) + .isNotNull().extracting(RbacUserEntity::getName).isEqualTo(givenNewUserName); + } + + @Test + @Commit + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void anyoneCanCreateTheirOwnUser_committed() { + + // given: + final var givenUuid = UUID.randomUUID(); + final var newUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; + + // when: + final var result = jpaAttempt.transacted(() -> { + currentUser("admin@aaa.example.com"); + return rbacUserRepository.create(new RbacUserEntity(givenUuid, newUserName)); + }); + + // then: + assertThat(result.wasSuccessful()).isTrue(); + assertThat(result.returnedValue()).isNotNull() + .extracting(RbacUserEntity::getUuid).isEqualTo(givenUuid); + jpaAttempt.transacted(() -> { + currentUser(newUserName); + assertThat(em.find(RbacUserEntity.class, givenUuid)) + .isNotNull().extracting(RbacUserEntity::getName).isEqualTo(newUserName); + }); + } + } + @Nested class FindByOptionalNameLike { @@ -210,7 +266,8 @@ class RbacUserRepositoryIntegrationTest { final var result = rbacUserRepository.findPermissionsOfUser("admin@aaa.example.com"); // then - exactlyTheseRbacPermissionsAreReturned(result, + exactlyTheseRbacPermissionsAreReturned( + result, // @formatter:off "customer#aaa.admin -> customer#aaa: add-package", "customer#aaa.admin -> customer#aaa: view", @@ -256,7 +313,8 @@ class RbacUserRepositoryIntegrationTest { final var result = rbacUserRepository.findPermissionsOfUser("aaa00@aaa.example.com"); // then - exactlyTheseRbacPermissionsAreReturned(result, + exactlyTheseRbacPermissionsAreReturned( + result, // @formatter:off "customer#aaa.tenant -> customer#aaa: view", // "customer#aaa.admin -> customer#aaa: view" - Not permissions through the customer admin! @@ -288,7 +346,8 @@ class RbacUserRepositoryIntegrationTest { final var result = rbacUserRepository.findPermissionsOfUser("aaa00@aaa.example.com"); // then - exactlyTheseRbacPermissionsAreReturned(result, + exactlyTheseRbacPermissionsAreReturned( + result, // @formatter:off "customer#aaa.tenant -> customer#aaa: view", // "customer#aaa.admin -> customer#aaa: view" - Not permissions through the customer admin! @@ -323,7 +382,6 @@ class RbacUserRepositoryIntegrationTest { .containsExactlyInAnyOrder(); } - void exactlyTheseRbacPermissionsAreReturned( final List actualResult, final String... expectedRoleNames) { diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/test/JpaAttempt.java index 6d77bac4..94b2eb14 100644 --- a/src/test/java/net/hostsharing/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/test/JpaAttempt.java @@ -1,7 +1,11 @@ package net.hostsharing.test; import junit.framework.AssertionFailedError; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityManager; import java.util.Optional; @@ -13,69 +17,98 @@ import static org.assertj.core.api.Assertions.assertThat; * Wraps the 'when' part of a DataJpaTest to improve readability of tests. *

* It - * - makes sure that the SQL code is actually performed (em.flush()), - * - if any exception is throw, it's caught and stored, - * - makes the result available for assertions, - * - cleans the JPA first level cache to force assertions read from the database, not just cache, - * - offers some assertions based on the exception. - * * - * - * @param success result type + *

  • makes sure that the SQL code is actually performed (em.flush()), + *
  • if any exception is throw, it's caught and stored, + *
  • makes the result available for assertions, + *
  • cleans the JPA first level cache to force assertions read from the database, not just cache, + *
  • offers some assertions based on the exception. + *

    + *

    + * To run in same transaction as caller, use the static `attempt` method, + * to run in a new transaction, inject this class and use the instance `transacted` methods. + *

    */ -public class JpaAttempt { +@Service +public class JpaAttempt { - private T result = null; - private RuntimeException exception = null; + @Autowired + private final EntityManager em; - private String firstRootCauseMessageLineOf(final RuntimeException exception) { - final var rootCause = NestedExceptionUtils.getRootCause(exception); - return Optional.ofNullable(rootCause) - .map(Throwable::getMessage) - .map(message -> message.split("\\r|\\n|\\r\\n", 0)[0]) - .orElse(null); + public JpaAttempt(final EntityManager em) { + this.em = em; } - public static JpaAttempt attempt(final EntityManager em, final Supplier code) { - return new JpaAttempt<>(em, code); - } - - public JpaAttempt(final EntityManager em, final Supplier code) { + public static JpaResult attempt(final EntityManager em, final Supplier code) { try { - result = code.get(); + final var result = new JpaResult(code.get(), null); em.flush(); em.clear(); + return result; } catch (RuntimeException exc) { - exception = exc; + return new JpaResult(null, exc); } } - public boolean wasSuccessful() { - return exception == null; + @Transactional(propagation = Propagation.REQUIRES_NEW) + public JpaResult transacted(final Supplier code) { + return attempt(em, code); } - public T returnedResult() { - return result; + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void transacted(final Runnable code) { + attempt(em, () -> { + code.run(); + return null; + }); } - public RuntimeException caughtException() { - return exception; - } + public static class JpaResult { - @SuppressWarnings("unchecked") - public E caughtException(final Class expectedExceptionClass) { - if (expectedExceptionClass.isAssignableFrom(exception.getClass())) { - return (E) exception; + final T result; + final RuntimeException exception; + + public JpaResult(final T result, final RuntimeException exception) { + this.result = result; + this.exception = exception; } - throw new AssertionFailedError("expected " + expectedExceptionClass + " but got " + exception); - } - public void assertExceptionWithRootCauseMessage( - final Class expectedExceptionClass, - final String... expectedRootCauseMessages) { - assertThat(wasSuccessful()).isFalse(); - final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass)); - for ( String expectedRootCauseMessage: expectedRootCauseMessages ) { - assertThat(firstRootCauseMessageLine).contains(expectedRootCauseMessage); + public boolean wasSuccessful() { + return exception == null; + } + + public T returnedValue() { + return result; + } + + public RuntimeException caughtException() { + return exception; + } + + @SuppressWarnings("unchecked") + public E caughtException(final Class expectedExceptionClass) { + if (expectedExceptionClass.isAssignableFrom(exception.getClass())) { + return (E) exception; + } + throw new AssertionFailedError("expected " + expectedExceptionClass + " but got " + exception); + } + + public void assertExceptionWithRootCauseMessage( + final Class expectedExceptionClass, + final String... expectedRootCauseMessages) { + assertThat(wasSuccessful()).isFalse(); + final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass)); + for (String expectedRootCauseMessage : expectedRootCauseMessages) { + assertThat(firstRootCauseMessageLine).contains(expectedRootCauseMessage); + } + } + + private String firstRootCauseMessageLineOf(final RuntimeException exception) { + final var rootCause = NestedExceptionUtils.getRootCause(exception); + return Optional.ofNullable(rootCause) + .map(Throwable::getMessage) + .map(message -> message.split("\\r|\\n|\\r\\n", 0)[0]) + .orElse(null); } } + } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 694e2d74..7f525a78 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,7 @@ spring: hibernate: default_schema: public dialect: net.hostsharing.hsadminng.config.PostgreSQL95CustomDialect + format_sql: false hibernate: ddl-auto: none show-sql: true @@ -29,3 +30,6 @@ spring: logging: level: liquibase: INFO + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + X.org.springframework.jdbc.core: TRACE