implements create new rbac-user and transacted JpaAttemp

This commit is contained in:
Michael Hoennig 2022-08-12 17:56:39 +02:00
parent dfc7162675
commit 41d3b678c4
16 changed files with 498 additions and 176 deletions

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.config; package net.hostsharing.hsadminng.config;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.openapitools.jackson.nullable.JsonNullableModule; import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -13,6 +14,6 @@ public class JsonObjectMapperConfiguration {
@Primary @Primary
public Jackson2ObjectMapperBuilder customObjectMapper() { public Jackson2ObjectMapperBuilder customObjectMapper() {
return new Jackson2ObjectMapperBuilder() return new Jackson2ObjectMapperBuilder()
.modules(new JsonNullableModule()); .modules(new JsonNullableModule(), new JavaTimeModule());
} }
} }

View File

@ -34,6 +34,13 @@ public class RestResponseEntityExceptionHandler
return errorResponse(request, httpStatus(message).orElse(HttpStatus.FORBIDDEN), message); return errorResponse(request, httpStatus(message).orElse(HttpStatus.FORBIDDEN), message);
} }
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> 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> httpStatus(final String message) { private Optional<HttpStatus> httpStatus(final String message) {
if (message.startsWith("ERROR: [")) { if (message.startsWith("ERROR: [")) {
for (HttpStatus status : HttpStatus.values()) { for (HttpStatus status : HttpStatus.values()) {
@ -48,10 +55,10 @@ public class RestResponseEntityExceptionHandler
private static ResponseEntity<CustomErrorResponse> errorResponse( private static ResponseEntity<CustomErrorResponse> errorResponse(
final WebRequest request, final WebRequest request,
final HttpStatus conflict, final HttpStatus httpStatus,
final String message) { final String message) {
return new ResponseEntity<>( return new ResponseEntity<>(
new CustomErrorResponse(request.getContextPath(), conflict, message), conflict); new CustomErrorResponse(request.getContextPath(), httpStatus, message), httpStatus);
} }
private String firstLine(final String message) { private String firstLine(final String message) {

View File

@ -17,4 +17,5 @@ public interface CustomerRepository extends Repository<CustomerEntity, UUID> {
CustomerEntity save(final CustomerEntity entity); CustomerEntity save(final CustomerEntity entity);
long count();
} }

View File

@ -7,10 +7,14 @@ import net.hostsharing.hsadminng.generated.api.v1.model.RbacUserResource;
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.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import javax.persistence.EntityManager;
import javax.transaction.Transactional; import javax.transaction.Transactional;
import java.util.List; import java.util.List;
import java.util.UUID;
import static net.hostsharing.hsadminng.Mapper.map;
import static net.hostsharing.hsadminng.Mapper.mapList; import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController @RestController
@ -19,15 +23,36 @@ public class RbacUserController implements RbacusersApi {
@Autowired @Autowired
private Context context; private Context context;
@Autowired
private EntityManager em;
@Autowired @Autowired
private RbacUserRepository rbacUserRepository; private RbacUserRepository rbacUserRepository;
@Override
@Transactional
public ResponseEntity<RbacUserResource> 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 @Override
@Transactional @Transactional
public ResponseEntity<List<RbacUserResource>> listUsers( public ResponseEntity<List<RbacUserResource>> listUsers(
@RequestHeader(name = "current-user") String currentUserName, @RequestHeader(name = "current-user") final String currentUserName,
@RequestHeader(name = "assumed-roles", required = false) String assumedRoles, @RequestHeader(name = "assumed-roles", required = false) final String assumedRoles,
@RequestParam(name="name", required = false) String userName @RequestParam(name = "name", required = false) final String userName
) { ) {
context.setCurrentUser(currentUserName); context.setCurrentUser(currentUserName);
if (assumedRoles != null && !assumedRoles.isBlank()) { if (assumedRoles != null && !assumedRoles.isBlank()) {
@ -39,9 +64,9 @@ public class RbacUserController implements RbacusersApi {
@Override @Override
@Transactional @Transactional
public ResponseEntity<List<RbacUserPermissionResource>> listUserPermissions( public ResponseEntity<List<RbacUserPermissionResource>> listUserPermissions(
@RequestHeader(name = "current-user") String currentUserName, @RequestHeader(name = "current-user") final String currentUserName,
@RequestHeader(name = "assumed-roles", required = false) String assumedRoles, @RequestHeader(name = "assumed-roles", required = false) final String assumedRoles,
@PathVariable(name= "userName") String userName @PathVariable(name = "userName") final String userName
) { ) {
context.setCurrentUser(currentUserName); context.setCurrentUser(currentUserName);
if (assumedRoles != null && !assumedRoles.isBlank()) { if (assumedRoles != null && !assumedRoles.isBlank()) {

View File

@ -1,16 +1,41 @@
package net.hostsharing.hsadminng.rbac.rbacuser; package net.hostsharing.hsadminng.rbac.rbacuser;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository; import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface RbacUserRepository extends Repository<RbacUserEntity, UUID> { public interface RbacUserRepository extends Repository<RbacUserEntity, UUID> {
@Query("SELECT u FROM RbacUserEntity u WHERE :userName is null or u.name like concat(:userName, '%')") @Query("""
List<RbacUserEntity> findByOptionalNameLike(final String userName); select u from RbacUserEntity u
where :userName is null or u.name like concat(:userName, '%')
order by u.name
""")
List<RbacUserEntity> findByOptionalNameLike(String userName);
@Query(value = "SELECT * FROM grantedPermissions(:userName)", nativeQuery = true) RbacUserEntity findByUuid(UUID uuid);
@Query(value = "select * from grantedPermissions(:userName)", nativeQuery = true)
List<RbacUserPermission> findPermissionsOfUser(String userName); List<RbacUserPermission> 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;
}
} }

View File

@ -14,7 +14,13 @@ components:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
Forbidden: 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: content:
application/json: application/json:
schema: schema:

View File

@ -21,19 +21,28 @@ get:
items: items:
$ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser'
"401": "401":
description: if the 'current-user' cannot be identified $ref: './api-definition/error-responses.yaml#/components/responses/Unauthorized'
content:
'application/json':
schema:
type: array
items:
$ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser'
"403": "403":
description: if the 'current-user' is not allowed to assume any of the roles $ref: './api-definition/error-responses.yaml#/components/responses/Forbidden'
from 'assumed-roles'
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: content:
'application/json': 'application/json':
schema: schema:
type: array $ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser'
items: "409":
$ref: './api-definition/rbac-user-schemas.yaml#/components/schemas/RbacUser' $ref: './api-definition/error-responses.yaml#/components/responses/Conflict'

View File

@ -59,6 +59,22 @@ begin
end; 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) create or replace function findRbacUserId(userName varchar)
returns uuid returns uuid
returns null on null input returns null on null input

View File

@ -9,11 +9,17 @@
*/ */
drop view if exists rbacrole_rv; drop view if exists rbacrole_rv;
create or replace view rbacrole_rv as create or replace view rbacrole_rv as
select DISTINCT r.*, o.objectTable, select *
findIdNameByObjectUuid(o.objectTable, o.uuid) as objectIdName -- @formatter:off
from rbacrole as r from (
join rbacobject as o on o.uuid=r.objectuuid select r.*, o.objectTable,
where isGranted(currentSubjectIds(), r.uuid); 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; 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; drop view if exists RbacUser_rv;
create or replace view RbacUser_rv as create or replace view RbacUser_rv as
select u.* select distinct *
from RbacUser as u -- @formatter:off
join RbacGrants as g on g.ascendantuuid = u.uuid from (
join rbacrole_rv as r on r.uuid = g.descendantuuid; 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; 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:--// --changeset rbac-views-OWN-GRANTED-PERMISSIONS-VIEW:1 endDelimiter:--//

View File

@ -36,19 +36,21 @@ class CustomerRepositoryIntegrationTest {
public void hostsharingAdmin_withoutAssumedRole_canCreateNewCustomer() { public void hostsharingAdmin_withoutAssumedRole_canCreateNewCustomer() {
// given // given
currentUser("mike@hostsharing.net"); currentUser("mike@hostsharing.net");
final var count = customerRepository.count();
// when // when
final var attempt = attempt(em, () -> { final var result = attempt(em, () -> {
final var newCustomer = new CustomerEntity( final var newCustomer = new CustomerEntity(
UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com"); UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com");
return customerRepository.save(newCustomer); return customerRepository.save(newCustomer);
}); });
// then // then
assertThat(attempt.wasSuccessful()).isTrue(); assertThat(result.wasSuccessful()).isTrue();
assertThat(attempt.returnedResult()).isNotNull().extracting(CustomerEntity::getUuid).isNotNull(); assertThat(result.returnedValue()).isNotNull().extracting(CustomerEntity::getUuid).isNotNull();
assertThatCustomerIsPersisted(attempt.returnedResult()); assertThatCustomerIsPersisted(result.returnedValue());
assertThat(customerRepository.count()).isEqualTo(count + 1);
} }
@Test @Test
@ -58,14 +60,14 @@ class CustomerRepositoryIntegrationTest {
assumedRoles("customer#aaa.admin"); assumedRoles("customer#aaa.admin");
// when // when
final var attempt = attempt(em, () -> { final var result = attempt(em, () -> {
final var newCustomer = new CustomerEntity( final var newCustomer = new CustomerEntity(
UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com"); UUID.randomUUID(), "xxx", 90001, "admin@xxx.example.com");
return customerRepository.save(newCustomer); return customerRepository.save(newCustomer);
}); });
// then // then
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
PersistenceException.class, PersistenceException.class,
"add-customer not permitted for customer#aaa.admin"); "add-customer not permitted for customer#aaa.admin");
} }
@ -76,14 +78,14 @@ class CustomerRepositoryIntegrationTest {
currentUser("admin@aaa.example.com"); currentUser("admin@aaa.example.com");
// when // when
final var attempt = attempt(em, () -> { final var result = attempt(em, () -> {
final var newCustomer = new CustomerEntity( final var newCustomer = new CustomerEntity(
UUID.randomUUID(), "yyy", 90002, "admin@yyy.example.com"); UUID.randomUUID(), "yyy", 90002, "admin@yyy.example.com");
return customerRepository.save(newCustomer); return customerRepository.save(newCustomer);
}); });
// then // then
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
PersistenceException.class, PersistenceException.class,
"add-customer not permitted for admin@aaa.example.com"); "add-customer not permitted for admin@aaa.example.com");
@ -152,12 +154,12 @@ class CustomerRepositoryIntegrationTest {
assumedRoles("package#aab00.admin"); assumedRoles("package#aab00.admin");
// when // when
final var attempt = attempt( final var result = attempt(
em, em,
() -> customerRepository.findCustomerByOptionalPrefixLike(null)); () -> customerRepository.findCustomerByOptionalPrefixLike(null));
// then // then
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin"); "[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() { void unknownUser_withoutAssumedRole_cannotViewAnyCustomers() {
currentUser("unknown@example.org"); currentUser("unknown@example.org");
final var attempt = attempt( final var result = attempt(
em, em,
() -> customerRepository.findCustomerByOptionalPrefixLike(null)); () -> customerRepository.findCustomerByOptionalPrefixLike(null));
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"hsadminng.currentUser defined as unknown@example.org, but does not exists"); "hsadminng.currentUser defined as unknown@example.org, but does not exists");
} }
@ -181,11 +183,11 @@ class CustomerRepositoryIntegrationTest {
currentUser("unknown@example.org"); currentUser("unknown@example.org");
assumedRoles("customer#aaa.admin"); assumedRoles("customer#aaa.admin");
final var attempt = attempt( final var result = attempt(
em, em,
() -> customerRepository.findCustomerByOptionalPrefixLike(null)); () -> customerRepository.findCustomerByOptionalPrefixLike(null));
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"hsadminng.currentUser defined as unknown@example.org, but does not exists"); "hsadminng.currentUser defined as unknown@example.org, but does not exists");
} }

View File

@ -85,12 +85,12 @@ class PackageRepositoryIntegrationTest {
assumedRoles("package#aab00.admin"); assumedRoles("package#aab00.admin");
// when // when
final var attempt = attempt( final var result = attempt(
em, em,
() -> packageRepository.findAllByOptionalNameLike(null)); () -> packageRepository.findAllByOptionalNameLike(null));
// then // then
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin"); "[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() { void unknownUser_withoutAssumedRole_cannotViewAnyPackages() {
currentUser("unknown@example.org"); currentUser("unknown@example.org");
final var attempt = attempt( final var result = attempt(
em, em,
() -> packageRepository.findAllByOptionalNameLike(null)); () -> packageRepository.findAllByOptionalNameLike(null));
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"hsadminng.currentUser defined as unknown@example.org, but does not exists"); "hsadminng.currentUser defined as unknown@example.org, but does not exists");
} }
@ -114,11 +114,11 @@ class PackageRepositoryIntegrationTest {
currentUser("unknown@example.org"); currentUser("unknown@example.org");
assumedRoles("customer#aaa.admin"); assumedRoles("customer#aaa.admin");
final var attempt = attempt( final var result = attempt(
em, em,
() -> packageRepository.findAllByOptionalNameLike(null)); () -> packageRepository.findAllByOptionalNameLike(null));
attempt.assertExceptionWithRootCauseMessage( result.assertExceptionWithRootCauseMessage(
JpaSystemException.class, JpaSystemException.class,
"hsadminng.currentUser defined as unknown@example.org, but does not exists"); "hsadminng.currentUser defined as unknown@example.org, but does not exists");
} }
@ -141,7 +141,6 @@ class PackageRepositoryIntegrationTest {
.isEmpty(); .isEmpty();
} }
void exactlyThesePackagesAreReturned(final List<PackageEntity> actualResult, final String... packageNames) { void exactlyThesePackagesAreReturned(final List<PackageEntity> actualResult, final String... packageNames) {
assertThat(actualResult) assertThat(actualResult)
.extracting(PackageEntity::getName) .extracting(PackageEntity::getName)

View File

@ -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");
}
}
}

View File

@ -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())));
}
}

View File

@ -2,21 +2,26 @@ package net.hostsharing.hsadminng.rbac.rbacuser;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.test.Array; import net.hostsharing.test.Array;
import net.hostsharing.test.JpaAttempt;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.ComponentScan;
import org.springframework.orm.jpa.JpaSystemException; 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 javax.persistence.EntityManager;
import java.util.List; import java.util.List;
import java.util.UUID;
import static net.hostsharing.test.JpaAttempt.attempt; import static net.hostsharing.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest @DataJpaTest
@ComponentScan(basePackageClasses = { Context.class, RbacUserRepository.class }) @ComponentScan(basePackageClasses = { RbacUserRepository.class, Context.class, JpaAttempt.class })
class RbacUserRepositoryIntegrationTest { class RbacUserRepositoryIntegrationTest {
@Autowired @Autowired
@ -25,8 +30,59 @@ class RbacUserRepositoryIntegrationTest {
@Autowired @Autowired
RbacUserRepository rbacUserRepository; RbacUserRepository rbacUserRepository;
@Autowired
JpaAttempt jpaAttempt;
@Autowired EntityManager em; @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 @Nested
class FindByOptionalNameLike { class FindByOptionalNameLike {
@ -210,7 +266,8 @@ class RbacUserRepositoryIntegrationTest {
final var result = rbacUserRepository.findPermissionsOfUser("admin@aaa.example.com"); final var result = rbacUserRepository.findPermissionsOfUser("admin@aaa.example.com");
// then // then
exactlyTheseRbacPermissionsAreReturned(result, exactlyTheseRbacPermissionsAreReturned(
result,
// @formatter:off // @formatter:off
"customer#aaa.admin -> customer#aaa: add-package", "customer#aaa.admin -> customer#aaa: add-package",
"customer#aaa.admin -> customer#aaa: view", "customer#aaa.admin -> customer#aaa: view",
@ -256,7 +313,8 @@ class RbacUserRepositoryIntegrationTest {
final var result = rbacUserRepository.findPermissionsOfUser("aaa00@aaa.example.com"); final var result = rbacUserRepository.findPermissionsOfUser("aaa00@aaa.example.com");
// then // then
exactlyTheseRbacPermissionsAreReturned(result, exactlyTheseRbacPermissionsAreReturned(
result,
// @formatter:off // @formatter:off
"customer#aaa.tenant -> customer#aaa: view", "customer#aaa.tenant -> customer#aaa: view",
// "customer#aaa.admin -> customer#aaa: view" - Not permissions through the customer admin! // "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"); final var result = rbacUserRepository.findPermissionsOfUser("aaa00@aaa.example.com");
// then // then
exactlyTheseRbacPermissionsAreReturned(result, exactlyTheseRbacPermissionsAreReturned(
result,
// @formatter:off // @formatter:off
"customer#aaa.tenant -> customer#aaa: view", "customer#aaa.tenant -> customer#aaa: view",
// "customer#aaa.admin -> customer#aaa: view" - Not permissions through the customer admin! // "customer#aaa.admin -> customer#aaa: view" - Not permissions through the customer admin!
@ -323,7 +382,6 @@ class RbacUserRepositoryIntegrationTest {
.containsExactlyInAnyOrder(); .containsExactlyInAnyOrder();
} }
void exactlyTheseRbacPermissionsAreReturned( void exactlyTheseRbacPermissionsAreReturned(
final List<RbacUserPermission> actualResult, final List<RbacUserPermission> actualResult,
final String... expectedRoleNames) { final String... expectedRoleNames) {

View File

@ -1,7 +1,11 @@
package net.hostsharing.test; package net.hostsharing.test;
import junit.framework.AssertionFailedError; import junit.framework.AssertionFailedError;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.NestedExceptionUtils; 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 javax.persistence.EntityManager;
import java.util.Optional; 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. * Wraps the 'when' part of a DataJpaTest to improve readability of tests.
* <p> * <p>
* It * It
* - makes sure that the SQL code is actually performed (em.flush()), * <li> makes sure that the SQL code is actually performed (em.flush()),
* - if any exception is throw, it's caught and stored, * <li> if any exception is throw, it's caught and stored,
* - makes the result available for assertions, * <li> makes the result available for assertions,
* - cleans the JPA first level cache to force assertions read from the database, not just cache, * <li> cleans the JPA first level cache to force assertions read from the database, not just cache,
* - offers some assertions based on the exception. * <li> offers some assertions based on the exception.
* * * </p>
* * <p>
* @param <T> success result type * 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.
* </p>
*/ */
public class JpaAttempt<T> { @Service
public class JpaAttempt {
private T result = null; @Autowired
private RuntimeException exception = null; private final EntityManager em;
private String firstRootCauseMessageLineOf(final RuntimeException exception) { public JpaAttempt(final EntityManager em) {
final var rootCause = NestedExceptionUtils.getRootCause(exception); this.em = em;
return Optional.ofNullable(rootCause)
.map(Throwable::getMessage)
.map(message -> message.split("\\r|\\n|\\r\\n", 0)[0])
.orElse(null);
} }
public static <T> JpaAttempt<T> attempt(final EntityManager em, final Supplier<T> code) { public static <T> JpaResult<T> attempt(final EntityManager em, final Supplier<T> code) {
return new JpaAttempt<>(em, code);
}
public JpaAttempt(final EntityManager em, final Supplier<T> code) {
try { try {
result = code.get(); final var result = new JpaResult<T>(code.get(), null);
em.flush(); em.flush();
em.clear(); em.clear();
return result;
} catch (RuntimeException exc) { } catch (RuntimeException exc) {
exception = exc; return new JpaResult<T>(null, exc);
} }
} }
public boolean wasSuccessful() { @Transactional(propagation = Propagation.REQUIRES_NEW)
return exception == null; public <T> JpaResult<T> transacted(final Supplier<T> code) {
return attempt(em, code);
} }
public T returnedResult() { @Transactional(propagation = Propagation.REQUIRES_NEW)
return result; public void transacted(final Runnable code) {
attempt(em, () -> {
code.run();
return null;
});
} }
public RuntimeException caughtException() { public static class JpaResult<T> {
return exception;
}
@SuppressWarnings("unchecked") final T result;
public <E extends RuntimeException> E caughtException(final Class<E> expectedExceptionClass) { final RuntimeException exception;
if (expectedExceptionClass.isAssignableFrom(exception.getClass())) {
return (E) 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( public boolean wasSuccessful() {
final Class<? extends RuntimeException> expectedExceptionClass, return exception == null;
final String... expectedRootCauseMessages) { }
assertThat(wasSuccessful()).isFalse();
final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass)); public T returnedValue() {
for ( String expectedRootCauseMessage: expectedRootCauseMessages ) { return result;
assertThat(firstRootCauseMessageLine).contains(expectedRootCauseMessage); }
public RuntimeException caughtException() {
return exception;
}
@SuppressWarnings("unchecked")
public <E extends RuntimeException> E caughtException(final Class<E> expectedExceptionClass) {
if (expectedExceptionClass.isAssignableFrom(exception.getClass())) {
return (E) exception;
}
throw new AssertionFailedError("expected " + expectedExceptionClass + " but got " + exception);
}
public void assertExceptionWithRootCauseMessage(
final Class<? extends RuntimeException> 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);
} }
} }
} }

View File

@ -14,6 +14,7 @@ spring:
hibernate: hibernate:
default_schema: public default_schema: public
dialect: net.hostsharing.hsadminng.config.PostgreSQL95CustomDialect dialect: net.hostsharing.hsadminng.config.PostgreSQL95CustomDialect
format_sql: false
hibernate: hibernate:
ddl-auto: none ddl-auto: none
show-sql: true show-sql: true
@ -29,3 +30,6 @@ spring:
logging: logging:
level: level:
liquibase: INFO liquibase: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
X.org.springframework.jdbc.core: TRACE