diff --git a/backend/src/main/groovy/edu/internet2/tier/shibboleth/admin/ui/configuration/DevConfig.groovy b/backend/src/main/groovy/edu/internet2/tier/shibboleth/admin/ui/configuration/DevConfig.groovy index e9e530d64..a225d7271 100644 --- a/backend/src/main/groovy/edu/internet2/tier/shibboleth/admin/ui/configuration/DevConfig.groovy +++ b/backend/src/main/groovy/edu/internet2/tier/shibboleth/admin/ui/configuration/DevConfig.groovy @@ -34,16 +34,29 @@ class DevConfig { @Transactional @PostConstruct - void createDevAdminUsers() { + void createDevUsers() { if (adminUserRepository.count() == 0) { - def user = new User().with { + def users = [new User().with { username = 'admin' password = '{noop}adminpass' + firstName = 'Joe' + lastName = 'Doe' + emailAddress = 'joe@institution.edu' roles.add(new Role(name: 'ROLE_ADMIN')) it + }, new User().with { + username = 'nonadmin' + password = '{noop}nonadminpass' + firstName = 'Peter' + lastName = 'Vandelay' + emailAddress = 'peter@institution.edu' + roles.add(new Role(name: 'ROLE_USER')) + it + }] + users.each { + adminUserRepository.save(it) } - adminUserRepository.save(user) } } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java index fb60112f2..b9679866d 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java @@ -7,6 +7,8 @@ import edu.internet2.tier.shibboleth.admin.ui.repository.MetadataResolversPositionOrderContainerRepository; import edu.internet2.tier.shibboleth.admin.ui.scheduled.EntityDescriptorFilesScheduledTasks; import edu.internet2.tier.shibboleth.admin.ui.scheduled.MetadataProvidersScheduledTasks; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.RoleRepository; +import edu.internet2.tier.shibboleth.admin.ui.security.service.UserRoleService; import edu.internet2.tier.shibboleth.admin.ui.service.DefaultMetadataResolversPositionOrderContainerService; import edu.internet2.tier.shibboleth.admin.ui.service.DirectoryService; import edu.internet2.tier.shibboleth.admin.ui.service.DirectoryServiceImpl; @@ -194,4 +196,9 @@ public Module stringTrimModule() { public ModelRepresentationConversions modelRepresentationConversions() { return new ModelRepresentationConversions(customPropertiesConfiguration()); } + + @Bean + public UserRoleService userRoleService(RoleRepository roleRepository) { + return new UserRoleService(roleRepository); + } } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/ConfigurationController.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/ConfigurationController.java index f71e76cb5..3f85175e1 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/ConfigurationController.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/ConfigurationController.java @@ -1,12 +1,16 @@ package edu.internet2.tier.shibboleth.admin.ui.controller; import edu.internet2.tier.shibboleth.admin.ui.configuration.CustomPropertiesConfiguration; +import edu.internet2.tier.shibboleth.admin.ui.security.model.Role; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.RoleRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import java.util.stream.Collectors; + /** * @author Bill Smith (wsmith@unicon.net) */ @@ -17,8 +21,16 @@ public class ConfigurationController { @Autowired CustomPropertiesConfiguration customPropertiesConfiguration; + @Autowired + RoleRepository roleRepository; + @GetMapping(value = "/customAttributes") public ResponseEntity getCustomAttributes() { return ResponseEntity.ok(customPropertiesConfiguration.getAttributes()); } + + @GetMapping(value = "/supportedRoles") + public ResponseEntity getSupportedRoles() { + return ResponseEntity.ok(roleRepository.findAll().stream().map(Role::getName).collect(Collectors.toList())); + } } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java index e210adc94..d918594db 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/support/RestControllersSupport.java @@ -35,13 +35,9 @@ public MetadataResolver findResolverOrThrowHttp404(String resolverResourceId) { return resolver; } - //TODO: Review this handler and update accordingly. Do we still need it? @ExceptionHandler(HttpClientErrorException.class) public ResponseEntity notFoundHandler(HttpClientErrorException ex) { - if(ex.getStatusCode() == NOT_FOUND) { - return ResponseEntity.status(NOT_FOUND).body(ex.getStatusText()); - } - throw ex; + return ResponseEntity.status(ex.getStatusCode()).body(new ErrorResponse(ex.getStatusCode().toString(), ex.getStatusText())); } @ExceptionHandler(ConstraintViolationException.class) diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersController.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersController.java new file mode 100644 index 000000000..0953d528c --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersController.java @@ -0,0 +1,116 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.controller; + +import edu.internet2.tier.shibboleth.admin.ui.controller.ErrorResponse; +import edu.internet2.tier.shibboleth.admin.ui.security.model.User; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.RoleRepository; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.UserRepository; +import edu.internet2.tier.shibboleth.admin.ui.security.service.UserRoleService; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.HttpClientErrorException; + +import java.util.List; +import java.util.Optional; + +import static org.springframework.http.HttpStatus.NOT_FOUND; + +/** + * Implementation of the REST resource endpoints exposing system users. + * + * @author Dmitriy Kopylenko + */ +@RestController +@RequestMapping("/api/admin/users") +public class UsersController { + + private static final Logger logger = LoggerFactory.getLogger(UsersController.class); + + private UserRepository userRepository; + private RoleRepository roleRepository; + private UserRoleService userRoleService; + + public UsersController(UserRepository userRepository, RoleRepository roleRepository, UserRoleService userRoleService) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.userRoleService = userRoleService; + } + + @Transactional(readOnly = true) + @GetMapping + public List getAll() { + return userRepository.findAll(); + } + + @Transactional(readOnly = true) + @GetMapping("/{username}") + public ResponseEntity getOne(@PathVariable String username) { + return ResponseEntity.ok(findUserOrThrowHttp404(username)); + } + + @Transactional + @DeleteMapping("/{username}") + public ResponseEntity deleteOne(@PathVariable String username) { + User user = findUserOrThrowHttp404(username); + userRepository.delete(user); + return ResponseEntity.noContent().build(); + } + + @Transactional + @PostMapping + ResponseEntity saveOne(@RequestBody User user) { + Optional persistedUser = userRepository.findByUsername(user.getUsername()); + if (persistedUser.isPresent()) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(new ErrorResponse(String.valueOf(HttpStatus.CONFLICT.value()), + String.format("A user with username [%s] already exists within the system.", user.getUsername()))); + } + //TODO: modify this such that additional encoders can be used + user.setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt())); + userRoleService.updateUserRole(user); + User savedUser = userRepository.save(user); + return ResponseEntity.ok(savedUser); + } + + @Transactional + @PatchMapping("/{username}") + ResponseEntity updateOne(@PathVariable(value = "username") String username, @RequestBody User user) { + User persistedUser = findUserOrThrowHttp404(username); + if (StringUtils.isNotBlank(user.getFirstName())) { + persistedUser.setFirstName(user.getFirstName()); + } + if (StringUtils.isNotBlank(user.getLastName())) { + persistedUser.setLastName(user.getLastName()); + } + if (StringUtils.isNotBlank(user.getEmailAddress())) { + persistedUser.setEmailAddress(user.getEmailAddress()); + } + if (StringUtils.isNotBlank(user.getPassword())) { + persistedUser.setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt())); + } + if (StringUtils.isNotBlank(user.getRole())) { + persistedUser.setRole(user.getRole()); + userRoleService.updateUserRole(persistedUser); + } + User savedUser = userRepository.save(persistedUser); + return ResponseEntity.ok(savedUser); + } + + private User findUserOrThrowHttp404(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new HttpClientErrorException(NOT_FOUND, String.format("User with username [%s] not found", username))); + } + } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Role.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Role.java index 20564093d..c7f1112b3 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Role.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Role.java @@ -1,5 +1,6 @@ package edu.internet2.tier.shibboleth.admin.ui.security.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import edu.internet2.tier.shibboleth.admin.ui.domain.AbstractAuditable; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -28,10 +29,23 @@ @ToString(exclude = "users") public class Role extends AbstractAuditable { + public Role(String name) { + this.name = name; + } + + public Role(String name, int rank) { + this.name = name; + this.rank = rank; + } + @Column(unique = true) private String name; - @ManyToMany(cascade = CascadeType.ALL, mappedBy = "roles", fetch = FetchType.EAGER) + private int rank; + + //Ignore properties annotation here is to prevent stack overflow recursive error during JSON serialization + @JsonIgnoreProperties("roles") + @ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER) private Set users = new HashSet<>(); } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/User.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/User.java index 9b24cf946..edb7c542a 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/User.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/User.java @@ -1,8 +1,14 @@ package edu.internet2.tier.shibboleth.admin.ui.security.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import edu.internet2.tier.shibboleth.admin.ui.domain.AbstractAuditable; -import lombok.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.apache.commons.lang.StringUtils; import javax.persistence.CascadeType; import javax.persistence.Column; @@ -10,6 +16,7 @@ import javax.persistence.JoinColumn; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; +import javax.persistence.Transient; import java.util.HashSet; import java.util.Set; @@ -37,8 +44,24 @@ public class User extends AbstractAuditable { private String lastName; - @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) - @ManyToMany(cascade = CascadeType.ALL) + private String emailAddress; + + @Transient + private String role; + + @JsonIgnore + @ManyToMany(cascade = CascadeType.PERSIST) @JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) private Set roles = new HashSet<>(); + + public String getRole() { + if (StringUtils.isBlank(this.role)) { + Set roles = this.getRoles(); + if (roles.size() != 1) { + throw new RuntimeException(String.format("User with username [%s] does not have exactly one role!", this.getUsername())); + } + this.role = roles.iterator().next().getName(); + } + return this.role; + } } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/UserRoleService.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/UserRoleService.java new file mode 100644 index 000000000..87a6431d0 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/UserRoleService.java @@ -0,0 +1,46 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.service; + +import edu.internet2.tier.shibboleth.admin.ui.security.model.Role; +import edu.internet2.tier.shibboleth.admin.ui.security.model.User; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.RoleRepository; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +/** + * @author Bill Smith (wsmith@unicon.net) + */ +public class UserRoleService { + + private RoleRepository roleRepository; + + public UserRoleService(RoleRepository roleRepository) { + this.roleRepository = roleRepository; + } + + /** + * Given a user with a defined User.role, update the User.roles collection with that role. + * + * This currently exists because users should only ever have one role in the system at this time. However, user + * roles are persisted as a set of roles (for future-proofing). Once we start allowing a user to have multiple roles, + * this method and User.role can go away. + * @param user + */ + public void updateUserRole(User user) { + if (StringUtils.isNotBlank(user.getRole())) { + Optional userRole = roleRepository.findByName(user.getRole()); + if (userRole.isPresent()) { + Set userRoles = new HashSet<>(); + userRoles.add(userRole.get()); + user.setRoles(userRoles); + } else { + throw new RuntimeException(String.format("User with username [%s] is defined with role [%s] which does not exist in the system!", user.getUsername(), user.getRole())); + } + } else { + throw new RuntimeException(String.format("User with username [%s] has no role defined and therefor cannot be updated!", user.getUsername())); + } + } +} diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 5b39cb7b9..1c207ee38 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -69,6 +69,10 @@ value.signing=Signing value.encryption=Encryption value.both=Both +value.entity=Entity +value.condition-ref=ConditionRef +value.condition-script=ConditionScript + value.file-backed-http-metadata-provider=FileBackedHttpMetadataProvider value.file-system-metadata-provider=FileSystemMetadataProvider value.local-dynamic-metadata-provider=LocalDynamicMetadataProvider @@ -130,6 +134,7 @@ label.clear-all-attributes=Clear All Attributes label.protocol-support-enumeration=Protocol Support Enumeration label.select-protocol=Select Protocol label.nameid-format=NameID Format +label.nameid-formats=NameID Formats label.name-and-entity-id=Name and Entity ID label.organization-information=Organization Information label.contact-information=Contact Information @@ -181,9 +186,9 @@ label.authentication-methods-to-use=Authentication Methods to Use label.auth-method-indexed=Authentication Method label.preview-provider=Preview XML label.search-entity-id=Search Entity Id -label.edit-filter=Edit EntityAttributesFilter +label.edit-filter=Edit Filter label.min-4-chars=Minimum 4 characters. -label.new-filter=New Filter - EntityAttributes +label.new-filter=New Filter label.service-provider=Metadata Source Name: label.created-date=Created Date: label.service-entity-id=Metadata Source Entity ID: @@ -299,7 +304,7 @@ label.metadata-provider-type=Metadata Provider Type label.metadata-provider-name=Metadata Provider Name label.select-metadata-type=Select a metadata provider type label.metadata-provider-status=Metadata Provider Status -label.enable-provider-upon-saving=If checkbox is clicked, the metadata provider is enabled for integration with the IdP +label.enable-provider-upon-saving=Enable Metadata Provider? label.certificate-type=Type label.metadata-file=Metadata File @@ -365,11 +370,37 @@ label.encoding-style=Encoding Style label.velocity-engine=Velocity Engine label.match=Match +label.remove-existing-formats=Remove Existing Formats? +label.nameid-formats-format=NameID Format +label.nameid-formats-value=NameID Value +label.nameid-formats-type=NameID Type + +label.select-filter-type=Select Filter Type + +label.admin=Admin +label.user-maintenance=User Maintenance +label.user-id=UserId +label.email=Email +label.role=Role +label.delete=Delete? + +message.delete-user-title=Delete User? +message.delete-user-body=You are requesting to delete a user. If you complete this process the user will be removed. This cannot be undone. Do you wish to continue? message.must-be-unique=Must be unique. message.name-must-be-unique=Name must be unique. message.uri-valid-format=URI must be valid format. message.id-unique=ID must be unique. +message.array-items-must-be-unique=Items in list must be unique. + +message.org-name-required=Organization Name is required. +message.org-displayName-required=Organization Name is required. +message.org-url-required=Organization Name is required. +message.org-incomplete=These three fields must all be entered if any single field has a value. + +message.type-required=Missing required property: Type +message.match-required=Missing required property: Match +message.value-required=Missing required property: Value message.conflict=Conflict message.data-version-contention=Data Version Contention @@ -416,8 +447,8 @@ tooltip.assertion-consumer-service-location=Assertion Consumer Service Location tooltip.assertion-consumer-service-location-binding=Assertion Consumer Service Location Binding tooltip.mark-as-default=Mark as Default tooltip.protocol-support-enumeration=Protocol Support Enumeration -tooltip.nameid-format=Add NameID Format -tooltip.enable-this-service-upon-saving=Enable this service upon saving +tooltip.nameid-format=Content is name identifier format which is added to all the applicable roles of the entities which match any of the following or {{}}elements. +tooltip.enable-this-service-upon-saving=If checkbox is clicked, the metadata provider is enabled for integration with the IdP tooltip.authentication-requests-signed=Authentication Requests Signed tooltip.want-assertions-signed=Want Assertions Signed tooltip.certificate-name=Certificate Name @@ -477,9 +508,9 @@ tooltip.enable-provider-upon-saving=If checkbox is clicked, the metadata provide tooltip.max-validity-interval=Defines the window within which the metadata is valid. tooltip.require-signed-root=If true, this fails to load metadata with no signature on the root XML element. tooltip.certificate-file=A key used to verify the signature. Conflicts with trustEngineRef and both of the child elements. -tooltip.retained-roles=Controls whether to keep entity descriptors that contain no roles -tooltip.remove-roleless-entity-descriptors=Controls whether to keep entity descriptors that contain no roles. -tooltip.remove-empty-entities-descriptors=Controls whether to keep entities descriptors that contain no entity descriptors. +tooltip.retained-roles=Note that property replacement cannot be used on this element. +tooltip.remove-roleless-entity-descriptors=Controls whether to keep entity descriptors that contain no roles. Note: If this attribute is set to false, the resulting output may not be schema-valid since an element must include at least one role descriptor. +tooltip.remove-empty-entities-descriptors=Controls whether to keep entities descriptors that contain no entity descriptors. Note: If this attribute is set to false, the resulting output may not be schema-valid since an element must include at least one child element, either an element or an element. tooltip.min-refresh-delay=Lower bound on the next refresh from the time calculated based on the metadata\u0027s expiration. tooltip.max-refresh-delay=Upper bound on the next refresh from the time calculated based on the metadata\u0027s expiration. @@ -503,9 +534,14 @@ tooltip.source-directory=Convenience mechanism for wiring a FilesystemLoadSaveMa tooltip.remove-idle-entity-data=Flag indicating whether idle metadata should be removed. tooltip.do-resolver-initialization=Initialize this resolver? In the case of Filesystem resolvers, this will cause the system to read the file and index the resolver. -tooltip.md-request-type=Options are 1) Metadata Query Protocol, 2) Template, 3) Regex. +tooltip.md-request-type=Options are 1) Metadata Query Protocol, 2) Regex. tooltip.md-request-value=Content of the element. tooltip.transform-ref=A reference to a transform function for the entityID. If used, the child element must be empty. tooltip.encoding-style=Determines whether and how the entityID value will be URL encoded prior to replacement. Allowed values are: 1) "none" - no encoding is performed, 2) "form" - encoded using URL form parameter encoding (for query parameters), 3) "path" - encoded using URL path encoding, or 4) "fragment" - encoded using URL fragment encoding. The precise definition of these terms is defined in the documentation for the methods of the Guava library\u0027s UrlEscapers class. tooltip.velocity-engine=This attribute may be used to specify the name of the Velocity engine defined within the application. -tooltip.match=A regular expression against which the entityID is evaluated. \ No newline at end of file +tooltip.match=A regular expression against which the entityID is evaluated. + +tooltip.remove-existing-formats=Whether to remove any existing formats from a role if any are added by the filter (unmodified roles will be untouched regardless of this setting) +tooltip.nameid-formats-format=Format +tooltip.nameid-formats-value=Value +tooltip.nameid-formats-type=Type \ No newline at end of file diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 35aa48c2e..1c207ee38 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -377,6 +377,16 @@ label.nameid-formats-type=NameID Type label.select-filter-type=Select Filter Type +label.admin=Admin +label.user-maintenance=User Maintenance +label.user-id=UserId +label.email=Email +label.role=Role +label.delete=Delete? + +message.delete-user-title=Delete User? +message.delete-user-body=You are requesting to delete a user. If you complete this process the user will be removed. This cannot be undone. Do you wish to continue? + message.must-be-unique=Must be unique. message.name-must-be-unique=Name must be unique. message.uri-valid-format=URI must be valid format. diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersControllerIntegrationTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersControllerIntegrationTests.groovy new file mode 100644 index 000000000..04aa033e9 --- /dev/null +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/UsersControllerIntegrationTests.groovy @@ -0,0 +1,151 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import groovy.json.JsonOutput +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ActiveProfiles +import spock.lang.Specification + +/** + * @author Dmitriy Kopylenko + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles(["no-auth", "dev"]) +class UsersControllerIntegrationTests extends Specification { + + @Autowired + private TestRestTemplate restTemplate + + static RESOURCE_URI = '/api/admin/users' + + ObjectMapper mapper + + def setup() { + mapper = new ObjectMapper() + mapper.enable(SerializationFeature.INDENT_OUTPUT) + } + + def 'GET ALL users (when there are existing users)'() { + when: 'GET request is made for ALL users in the system, and system has users in it' + def result = this.restTemplate.getForEntity(RESOURCE_URI, Object) + + then: 'Request completed with HTTP 200 and returned a list of users' + result.statusCodeValue == 200 + result.body[0].username == 'admin' + result.body[0].role == 'ROLE_ADMIN' + } + + def 'GET ONE existing user'() { + when: 'GET request is made for one existing user' + def result = this.restTemplate.getForEntity("$RESOURCE_URI/admin", Map) + + then: 'Request completed with HTTP 200 and returned one user' + result.statusCodeValue == 200 + result.body.username == 'admin' + result.body.role == 'ROLE_ADMIN' + } + + def 'GET ONE NON-existing user'() { + when: 'GET request is made for one NON-existing user' + def result = this.restTemplate.getForEntity("$RESOURCE_URI/bogus", Map) + + then: 'Request completed with HTTP 404' + result.statusCodeValue == 404 + result.body.errorCode == '404' + result.body.errorMessage == 'User with username [bogus] not found' + } + + @DirtiesContext + def 'DELETE ONE existing user'() { + when: 'GET request is made for one existing user' + def result = this.restTemplate.getForEntity("$RESOURCE_URI/admin", Map) + + then: 'Request completed with HTTP 200' + result.statusCodeValue == 200 + + when: 'DELETE request is made' + this.restTemplate.delete("$RESOURCE_URI/admin") + result = this.restTemplate.getForEntity("$RESOURCE_URI/admin", Map) + + then: 'The deleted user is gone' + result.statusCodeValue == 404 + } + + def 'POST new user persists properly'() { + given: + def newUser = [firstName: 'Foo', + lastName: 'Bar', + username: 'FooBar', + password: 'somepass', + emailAddress: 'foo@institution.edu', + role: 'ROLE_USER'] + + when: + def result = this.restTemplate.postForEntity("$RESOURCE_URI", createRequestHttpEntityFor { JsonOutput.toJson(newUser) }, Map) + + then: + result.statusCodeValue == 200 + } + + def 'POST new duplicate username returns 409'() { + given: + def newUser = [firstName: 'Foo', + lastName: 'Bar', + username: 'DuplicateUser', + password: 'somepass', + emailAddress: 'foo@institution.edu', + role: 'ROLE_USER'] + + when: + this.restTemplate.postForEntity("$RESOURCE_URI", createRequestHttpEntityFor { JsonOutput.toJson(newUser) }, Map) + def result = this.restTemplate.postForEntity("$RESOURCE_URI", createRequestHttpEntityFor { JsonOutput.toJson(newUser) }, Map) + + then: + result.statusCodeValue == 409 + } + + def 'PUT updates user properly'() { + given: + def newUser = [firstName: 'Foo', + lastName: 'Bar', + username: 'FooBar', + password: 'somepass', + emailAddress: 'foo@institution.edu', + role: 'ROLE_USER'] + + when: + this.restTemplate.postForEntity("$RESOURCE_URI", createRequestHttpEntityFor { JsonOutput.toJson(newUser) }, Map) + newUser['firstName'] = 'Bob' + def result = this.restTemplate.exchange("$RESOURCE_URI/$newUser.username", HttpMethod.PATCH, createRequestHttpEntityFor { JsonOutput.toJson(newUser) }, Map) + + then: + result.statusCodeValue == 200 + } + + def 'PATCH detects unknown username'() { + given: + def newUser = [firstName: 'Foo', + lastName: 'Bar', + username: 'UnknownUser', + password: 'somepass', + emailAddress: 'foo@institution.edu', + role: 'ROLE_USER'] + + when: + def result = this.restTemplate.exchange("$RESOURCE_URI/$newUser.username", HttpMethod.PATCH, createRequestHttpEntityFor { mapper.writeValueAsString(newUser) }, Map) + + then: + result.statusCodeValue == 404 + } + + private HttpEntity createRequestHttpEntityFor(Closure jsonBodySupplier) { + new HttpEntity(jsonBodySupplier(), ['Content-Type': 'application/json'] as HttpHeaders) + } +} \ No newline at end of file diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index b7adb997b..fe988c62f 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -42,7 +42,7 @@