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 9e97395db..6080b2f20 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 @@ -12,8 +12,10 @@ import edu.internet2.tier.shibboleth.admin.ui.domain.resolvers.ReloadableMetadat import edu.internet2.tier.shibboleth.admin.ui.opensaml.OpenSamlObjects import edu.internet2.tier.shibboleth.admin.ui.repository.EntityDescriptorRepository import edu.internet2.tier.shibboleth.admin.ui.repository.MetadataResolverRepository +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group 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.GroupsRepository 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.util.ModelRepresentationConversions @@ -29,6 +31,7 @@ import javax.annotation.PostConstruct @Profile('dev') class DevConfig { private final UserRepository adminUserRepository + private final GroupsRepository groupsRepository private final RoleRepository roleRepository private final MetadataResolverRepository metadataResolverRepository @@ -37,6 +40,7 @@ class DevConfig { private final OpenSamlObjects openSamlObjects DevConfig(UserRepository adminUserRepository, + GroupsRepository groupsRepository, MetadataResolverRepository metadataResolverRepository, RoleRepository roleRepository, EntityDescriptorRepository entityDescriptorRepository, @@ -47,11 +51,32 @@ class DevConfig { this.roleRepository = roleRepository this.entityDescriptorRepository = entityDescriptorRepository this.openSamlObjects = openSamlObjects + this.groupsRepository = groupsRepository } @Transactional @PostConstruct - void createDevUsers() { + void createDevUsersAndGroups() { + if (groupsRepository.count() == 0) { + def groups = [ + new Group().with { + it.name = "A1" + it.description = "AAA Group" + it.resourceId = "AAA" + it + }, + new Group().with { + it.name = "B1" + it.description = "BBB Group" + it.resourceId = "BBB" + it + }] + groups.each { + groupsRepository.save(it) + } + } + groupsRepository.flush() + if (roleRepository.count() == 0) { def roles = [new Role().with { name = 'ROLE_ADMIN' diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupController.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupController.java new file mode 100644 index 000000000..da6d5edae --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupController.java @@ -0,0 +1,114 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import edu.internet2.tier.shibboleth.admin.ui.controller.ErrorResponse; +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group; +import edu.internet2.tier.shibboleth.admin.ui.security.service.IGroupService; + +@Controller +@RequestMapping(value = "/api/admin/groups") +public class GroupController { + @Autowired + private IGroupService groupService; + + @PostMapping + @Transactional + public ResponseEntity create(@RequestBody Group group) { + // If already defined, we can't create a new one, nor will this call update the definition + Group foundGroup = groupService.find(group.getResourceId()); + + if (foundGroup != null) { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(ServletUriComponentsBuilder.fromCurrentServletMapping().path("/api/admin/groups").build().toUri()); + + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).headers(headers) + .body(new ErrorResponse(String.valueOf(HttpStatus.METHOD_NOT_ALLOWED.value()), + String.format("The group with resource id: [%s] and name: [%s] already exists.", + group.getResourceId(), group.getName()))); + } + + Group result = groupService.createOrUpdateGroup(group); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + @PutMapping + @Transactional + public ResponseEntity update(@RequestBody Group group) { + Group g = groupService.find(group.getResourceId()); + + if (g == null) { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(ServletUriComponentsBuilder.fromCurrentServletMapping().path("/api/admin/groups").build().toUri()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).headers(headers) + .body(new ErrorResponse(String.valueOf(HttpStatus.NOT_FOUND.value()), + String.format("Unable to find group with resource id: [%s] and name: [%s]", + group.getResourceId(), group.getName()))); + } + + Group result = groupService.createOrUpdateGroup(group); + return ResponseEntity.ok(result); + } + + @GetMapping + @Transactional(readOnly = true) + public ResponseEntity getAll() { + return ResponseEntity.ok(groupService.findAll()); + } + + @GetMapping("/{resourceId}") + @Transactional(readOnly = true) + public ResponseEntity getOne(@PathVariable String resourceId) { + Group g = groupService.find(resourceId); + + if (g == null) { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(ServletUriComponentsBuilder.fromCurrentServletMapping().path("/api/admin/groups").build().toUri()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).headers(headers) + .body(new ErrorResponse(String.valueOf(HttpStatus.NOT_FOUND.value()), + String.format("Unable to find group with resource id: [%s]", resourceId))); + } + return ResponseEntity.ok(g); + } + + @DeleteMapping("/{resourceId}") + @Transactional + public ResponseEntity delete(@PathVariable String resourceId) { + Group g = groupService.find(resourceId); + + if (g == null) { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(ServletUriComponentsBuilder.fromCurrentServletMapping().path("/api/admin/groups").build().toUri()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).headers(headers) + .body(new ErrorResponse(String.valueOf(HttpStatus.NOT_FOUND.value()), + String.format("Unable to find group with resource id: [%s]", resourceId))); + } + if (!g.getUsers().isEmpty()) { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(ServletUriComponentsBuilder.fromCurrentServletMapping().path("/api/admin/groups").build().toUri()); + + return ResponseEntity.status(HttpStatus.CONFLICT).headers(headers) + .body(new ErrorResponse(String.valueOf(HttpStatus.CONFLICT.value()), String.format( + "Unable to delete group with resource id: [%s] - remove all users from group first", + resourceId))); + } + groupService.deleteDefinition(g); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Group.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Group.java new file mode 100644 index 000000000..db903e988 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/model/Group.java @@ -0,0 +1,35 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.model; + +import java.util.Set; +import java.util.UUID; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Entity(name = "user_groups") +@Data +public class Group { + @Column(name = "group_description", nullable = true) + String description; + + @Column(nullable = false) + String name; + + @Id + @Column(name = "resource_id") + String resourceId = UUID.randomUUID().toString(); + + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @JsonIgnore + @EqualsAndHashCode.Exclude + Set users; +} 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 c30c4ceff..df4a96e13 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 @@ -10,12 +10,15 @@ import lombok.ToString; import org.apache.commons.lang.StringUtils; +import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.JoinColumn; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToOne; import javax.persistence.Table; import javax.persistence.Transient; import java.util.HashSet; @@ -30,23 +33,25 @@ @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(callSuper = true, exclude = "roles") +@EqualsAndHashCode(callSuper = true) @ToString(exclude = "roles") @Table(name = "USERS") public class User extends AbstractAuditable { - @Column(nullable = false, unique = true) - private String username; - - @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) - @Column(nullable = false) - private String password; + private String emailAddress; private String firstName; + @ManyToOne + @JoinColumn(name = "resource_id") + @EqualsAndHashCode.Exclude + private Group group; + private String lastName; - private String emailAddress; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @Column(nullable = false) + private String password; @Transient private String role; @@ -54,8 +59,12 @@ public class User extends AbstractAuditable { @JsonIgnore @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) + @EqualsAndHashCode.Exclude private Set roles = new HashSet<>(); + @Column(nullable = false, unique = true) + private String username; + public String getRole() { if (StringUtils.isBlank(this.role)) { Set roles = this.getRoles(); diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepository.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepository.java new file mode 100644 index 000000000..9576184e4 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepository.java @@ -0,0 +1,16 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group; + +public interface GroupsRepository extends JpaRepository { + List findAll(); + + Group findByResourceId(String id); + + @SuppressWarnings("unchecked") + Group save(Group group); +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/GroupServiceImpl.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/GroupServiceImpl.java new file mode 100644 index 000000000..f4c3a1344 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/GroupServiceImpl.java @@ -0,0 +1,36 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.service; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group; +import edu.internet2.tier.shibboleth.admin.ui.security.repository.GroupsRepository; + +@Service +public class GroupServiceImpl implements IGroupService { + @Autowired + private GroupsRepository repo; + + @Override + public Group createOrUpdateGroup(Group group) { + return repo.save(group); + } + + @Override + public void deleteDefinition(Group group) { + repo.delete(group); + } + + @Override + public Group find(String resourceId) { + return repo.findByResourceId(resourceId); + } + + @Override + public List findAll() { + return repo.findAll(); + } + +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/IGroupService.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/IGroupService.java new file mode 100644 index 000000000..1e040a9d1 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/security/service/IGroupService.java @@ -0,0 +1,17 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.service; + +import java.util.List; + +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group; + +public interface IGroupService { + + Group createOrUpdateGroup(Group g); + + void deleteDefinition(Group g); + + Group find(String resourceId); + + List findAll(); + +} diff --git a/backend/src/main/resources/nameid-filter.schema.json b/backend/src/main/resources/nameid-filter.schema.json index d6d02c84f..0865d8f89 100644 --- a/backend/src/main/resources/nameid-filter.schema.json +++ b/backend/src/main/resources/nameid-filter.schema.json @@ -21,7 +21,7 @@ "type": "object", "properties": { "nameIdFormatFilterTargetType": { - "title": "", + "title": "label.filter-target-type", "type": "string", "default": "ENTITY", "enum": [ diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupsControllerIntegrationTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupsControllerIntegrationTests.groovy new file mode 100644 index 000000000..3b514c065 --- /dev/null +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/controller/GroupsControllerIntegrationTests.groovy @@ -0,0 +1,226 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.controller + + +import groovy.json.JsonOutput +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.annotation.Rollback +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.result.MockMvcResultHandlers +import org.springframework.transaction.annotation.Transactional + +import spock.lang.Ignore +import spock.lang.Specification + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(["no-auth", "dev"]) +@Transactional +class GroupsControllerIntegrationTests extends Specification { + @Autowired + private MockMvc mockMvc + + static RESOURCE_URI = '/api/admin/groups' + static USERS_RESOURCE_URI = '/api/admin/users' + + @Rollback + @WithMockUser(value = "admin", roles = ["ADMIN"]) + def 'POST new group persists properly'() { + given: + def newGroup = [name: 'Foo', + description: 'Bar', + resourceId: 'FooBar'] + + def expectedJson = """ + { + "name":"Foo", + "description":"Bar", + "resourceId":"FooBar" + } +""" + + when: + def result = mockMvc.perform(post(RESOURCE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonOutput.toJson(newGroup)) + .accept(MediaType.APPLICATION_JSON)) + + then: + result.andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedJson, false)) + + when: 'Try to create with an existing resource id' + result = mockMvc.perform(post(RESOURCE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonOutput.toJson(newGroup)) + .accept(MediaType.APPLICATION_JSON)) + + then: 'Expecting method not allowed' + result.andExpect(status().isMethodNotAllowed()) + } + + @Rollback + @WithMockUser(value = "admin", roles = ["ADMIN"]) + def 'PUT (update) existing group persists properly'() { + given: + def group = [name: 'NOT AAA', + description: 'updated AAA', + resourceId: 'AAA'] + + def expectedJson = """ + { + "name":"NOT AAA", + "description":"updated AAA", + "resourceId":"AAA" + } +""" + when: + def result = mockMvc.perform(put(RESOURCE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonOutput.toJson(group)) + .accept(MediaType.APPLICATION_JSON)) + + then: + result.andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedJson, false)) + + when: 'Try to update with a non-existing resource id' + def newGroup = [name: 'XXXXX', + description: 'should not work', + resourceId: 'XXXX'] + def notFoundresult = mockMvc.perform(put(RESOURCE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonOutput.toJson(newGroup)) + .accept(MediaType.APPLICATION_JSON)) + + then: 'Expecting nothing happened because the object was not found' + notFoundresult.andExpect(status().isNotFound()) + } + + @WithMockUser(value = "admin", roles = ["ADMIN"]) + def 'GET checks for groups (when there are existing groups)'() { + given: + def expectedJson = """ +[ + { + "name":"A1", + "description":"AAA Group", + "resourceId":"AAA" + }, + { + "name":"B1", + "description":"BBB Group", + "resourceId":"BBB" + } +]""" + when: 'GET request is made for ALL groups in the system, and system has groups in it' + def result = mockMvc.perform(get(RESOURCE_URI)) + + then: 'Request completed with HTTP 200 and returned a list of users' + result.andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedJson, false)) + + when: 'GET request for a single specific group in a system that has groups' + def singleGroupRequest = mockMvc.perform(get("$RESOURCE_URI/BBB")) + + then: 'GET request for a single specific group completed with HTTP 200' + singleGroupRequest.andExpect(status().isOk()) + + when: 'GET request for a single non-existent group in a system that has groups' + def nonexistentGroupRequest = mockMvc.perform(get("$RESOURCE_URI/CCC")) + + then: 'The group not found' + nonexistentGroupRequest.andExpect(status().isNotFound()) + } + +// @Rollback +// @WithMockUser(value = "admin", roles = ["ADMIN"]) +// def 'DELETE ONE existing group'() { +// when: 'GET request for a single specific group in a system that has groups' +// def result = mockMvc.perform(get("$RESOURCE_URI/BBB")) +// +// then: 'GET request for a single specific group completed with HTTP 200' +// result.andExpect(status().isOk()) +// +// when: 'DELETE request is made' +// result = mockMvc.perform(delete("$RESOURCE_URI/BBB")) +// +// then: 'DELETE was successful' +// result.andExpect(status().isNoContent()) +// +// when: 'GET request for a single specific group just deleted' +// result = mockMvc.perform(get("$RESOURCE_URI/BBB")) +// +// then: 'The group not found' +// result.andExpect(status().isNotFound()) +// +// when: 'DELETE request for a single specific group that does not exist' +// result = mockMvc.perform(delete("$RESOURCE_URI/CCCC")) +// +// then: 'The group not found' +// result.andExpect(status().isNotFound()) +// } + + @Rollback + @WithMockUser(value = "admin", roles = ["ADMIN"]) + def 'DELETE performs correctly when group attached to a user'() { + given: + def group = [resourceId: 'AAA'] + def newUser = [firstName: 'Foo', + lastName: 'Bar', + username: 'FooBar', + password: 'somepass', + emailAddress: 'foo@institution.edu', + role: 'ROLE_USER', + group: group] + + when: + def result = mockMvc.perform(post(USERS_RESOURCE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonOutput.toJson(newUser)) + .accept(MediaType.APPLICATION_JSON)) + + then: + result.andExpect(status().isOk()) + + when: + def userresult = mockMvc.perform(get("$USERS_RESOURCE_URI/$newUser.username")) + def expectedJson = """ +{ + "modifiedBy" : admin, + "firstName" : "Foo", + "emailAddress" : "foo@institution.edu", + "role" : "ROLE_USER", + "username" : "FooBar", + "createdBy" : admin, + "lastName" : "Bar", + "group" : {"resourceId":"AAA"} +}""" + then: 'Request completed with HTTP 200 and returned one user' + userresult.andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedJson, false)) + + + when: 'DELETE request is made' + result = mockMvc.perform(delete("$RESOURCE_URI/$group.resourceId")) + + then: 'DELETE was not successful' + result.andExpect(status().isConflict()) + } +} diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepositoryTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepositoryTests.groovy new file mode 100644 index 000000000..20fcc4f8a --- /dev/null +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/security/repository/GroupsRepositoryTests.groovy @@ -0,0 +1,143 @@ +package edu.internet2.tier.shibboleth.admin.ui.security.repository + +import javax.persistence.EntityManager + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration + +import edu.internet2.tier.shibboleth.admin.ui.configuration.InternationalizationConfiguration +import edu.internet2.tier.shibboleth.admin.ui.security.model.Group +import spock.lang.Specification + +/** + * Tests to validate the repo and model for groups + * @author chasegawa + */ +@DataJpaTest +@ContextConfiguration(classes=[InternationalizationConfiguration]) +@EnableJpaRepositories(basePackages = ["edu.internet2.tier.shibboleth.admin.ui"]) +@EntityScan("edu.internet2.tier.shibboleth.admin.ui") +class GroupsRepositoryTests extends Specification { + @Autowired + GroupsRepository repo + + @Autowired + EntityManager entityManager + + def "simple create test"() { + given: + def group = new Group().with { + it.name = "group-name" + it.description = "some description" + it + } + + // Confirm empty state + when: + def groups = repo.findAll() + + then: + groups.size() == 0 + + // save check + when: + repo.save(group) + entityManager.flush() + entityManager.clear() + + then: + // save check + def gList = repo.findAll() + gList.size() == 1 + def groupFromDb = gList.get(0).asType(Group) + groupFromDb.equals(group) == true + + // fetch checks + repo.findByResourceId("not an id") == null + repo.findByResourceId(groupFromDb.resourceId).equals(group) + } + + def "expected error"() { + given: + def group = new Group().with { + it.description = "some description" + it + } + + // Confirm empty state + when: + def gList = repo.findAll() + + then: + gList.size() == 0 + + // save check + when: + repo.save(group) + entityManager.flush() + entityManager.clear() + + then: + // Missing non-nullable field (name) should thrown error + final def exception = thrown(javax.persistence.PersistenceException) + } + + def "basic CRUD operations validated"() { + given: + def group = new Group().with { + it.name = "group-name" + it.description = "some description" + it + } + + // Confirm empty state + when: + def groups = repo.findAll() + + then: + groups.size() == 0 + + // save check + when: + repo.save(group) + entityManager.flush() + entityManager.clear() + + then: + // save check + def gList = repo.findAll() + gList.size() == 1 + def groupFromDb = gList.get(0).asType(Group) + groupFromDb.equals(group) == true + + // update check + groupFromDb.with { + it.description = "some new text that wasn't there before" + } + groupFromDb.equals(group) == false + + when: + repo.save(groupFromDb) + entityManager.flush() + entityManager.clear() + + then: + def gList2 = repo.findAll() + gList2.size() == 1 + def groupFromDb2 = gList2.get(0).asType(Group) + groupFromDb2.equals(group) == false + groupFromDb2.equals(groupFromDb) == true + + // delete tests + when: + repo.delete(groupFromDb2) + entityManager.flush() + entityManager.clear() + + then: + repo.findAll().size() == 0 + } +} \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index ab86d547d..8cbf388c2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -13213,22 +13213,6 @@ } } }, - "react-contenteditable": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.5.tgz", - "integrity": "sha512-38A7hlRQfb2KQAQT0kIJC2YlQUU7jcyYM4eh1fj6kAYb3Hmk6hHlr0snelyxVSpPXjPdFllrnSsPkzUS5AtrEA==", - "requires": { - "fast-deep-equal": "^2.0.1", - "prop-types": "^15.7.1" - }, - "dependencies": { - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" - } - } - }, "react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -13607,6 +13591,11 @@ "prop-types": "^15.7.2" } }, + "react-simple-code-editor": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.11.0.tgz", + "integrity": "sha512-xGfX7wAzspl113ocfKQAR8lWPhavGWHL3xSzNLeseDRHysT+jzRBi/ExdUqevSMos+7ZtdfeuBOXtgk9HTwsrw==" + }, "react-transition-group": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", diff --git a/ui/package.json b/ui/package.json index 1046146a3..80e8551f8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,12 +20,12 @@ "react": "^17.0.2", "react-bootstrap": "^1.5.2", "react-bootstrap-typeahead": "^5.1.4", - "react-contenteditable": "^3.3.5", "react-dom": "^17.0.2", "react-hook-form": "^7.5.2", "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^5.2.0", "react-scroll": "^1.8.2", + "react-simple-code-editor": "^0.11.0", "use-http": "^1.0.20", "use-query-params": "^1.2.2", "web-vitals": "^1.0.1" diff --git a/ui/src/app/dashboard/view/Dashboard.js b/ui/src/app/dashboard/view/Dashboard.js index 5869e5695..d7892f028 100644 --- a/ui/src/app/dashboard/view/Dashboard.js +++ b/ui/src/app/dashboard/view/Dashboard.js @@ -40,7 +40,7 @@ export function Dashboard () { } async function loadSources() { - const s = sourceLoader.get(); + const s = await sourceLoader.get(); if (response.ok) { setSources(s); } diff --git a/ui/src/app/form/component/fields/FilterTargetField.js b/ui/src/app/form/component/fields/FilterTargetField.js index 2bfd8484d..959306b71 100644 --- a/ui/src/app/form/component/fields/FilterTargetField.js +++ b/ui/src/app/form/component/fields/FilterTargetField.js @@ -6,12 +6,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faAsterisk, faCaretDown, faCaretUp, faEye, faEyeSlash, faPlus, faSpinner, faTrash } from '@fortawesome/free-solid-svg-icons'; import { useTranslator } from '../../../i18n/hooks'; import { InfoIcon } from '../InfoIcon'; -import ContentEditable from 'react-contenteditable'; import { AsyncTypeahead } from 'react-bootstrap-typeahead'; import useFetch from 'use-http'; import queryString from 'query-string'; import API_BASE_PATH from '../../../App.constant'; import isNil from 'lodash/isNil'; +import Editor from 'react-simple-code-editor'; +// import { highlight, languages } from 'prismjs/components/prism-core'; +// import 'prismjs/components/prism-clike'; +// import 'prismjs/components/prism-javascript'; import { FilterTargetPreview } from '../../../metadata/hoc/FilterTargetPreview'; @@ -89,8 +92,6 @@ const FilterTargetField = ({ const displayType = selectedType?.label || ''; const targetType = selectedType?.value || null; - const ref = React.useRef(selectedTarget[0]); - var handleTextChange = function (value) { setSelectedTarget([value]); }; @@ -187,20 +188,22 @@ const FilterTargetField = ({ } { targetType === 'CONDITION_SCRIPT' && - <> - handleTextChange(value)} - html={ selectedTarget[0] } - innerRef={ref} - dangerouslySetInnerHTML={true}> - - +
+ code} + onValueChange={(code) => handleTextChange(code)} + padding={10} + className={`codearea border rounded ${!selectedTarget[0] && 'is-invalid border-danger'}`} + style={{ + fontFamily: 'monospace', + fontSize: 15, + }}> + + {!selectedTarget[0] && Required for Scripts - - } + } +
} {targetType === 'REGEX' && <> { + return property.enum && property.enumNames ? property.enumNames[property.enum.indexOf(v)] : v; + } + return (
{property.differences && Changed: } @@ -21,7 +25,7 @@ export function PrimitiveProperty ({ property, columns }) { { property.name } {property.value.map((v, valIdx) => - + ) }
diff --git a/ui/src/app/metadata/component/properties/PropertyValue.js b/ui/src/app/metadata/component/properties/PropertyValue.js index 807c86e61..0476fdb01 100644 --- a/ui/src/app/metadata/component/properties/PropertyValue.js +++ b/ui/src/app/metadata/component/properties/PropertyValue.js @@ -3,6 +3,7 @@ import Popover from 'react-bootstrap/Popover'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import { usePropertyWidth } from './hooks'; +import Translate from '../../../i18n/components/translate'; export function PropertyValue ({ name, value, columns, className }) { @@ -20,7 +21,9 @@ export function PropertyValue ({ name, value, columns, className }) { className={`d-block text-truncate ${className}`} role="definition" style={columns ? { width } : {}}> - {value !== undefined ? value.toString() : (value === false) ? value.toString() : '-'} + + {value !== undefined ? value.toString() : (value === false) ? value.toString() : '-'} + : -} diff --git a/ui/src/app/metadata/domain/filter/EntityAttributesFilterDefinition.js b/ui/src/app/metadata/domain/filter/EntityAttributesFilterDefinition.js index 727df9f8f..036557706 100644 --- a/ui/src/app/metadata/domain/filter/EntityAttributesFilterDefinition.js +++ b/ui/src/app/metadata/domain/filter/EntityAttributesFilterDefinition.js @@ -53,6 +53,13 @@ export const EntityAttributesFilterWizard = { errors.entityAttributesFilterTarget.value.addError('message.invalid-regex-pattern'); } } + + if (formData?.entityAttributesFilterTarget?.entityAttributesFilterTargetType === 'CONDITION_SCRIPT') { + const { entityAttributesFilterTarget: { value } } = formData; + if (!value[0]) { + errors.entityAttributesFilterTarget.value.addError('message.required-for-scripts'); + } + } return errors; } }, diff --git a/ui/src/app/metadata/domain/filter/NameIdFilterDefinition.js b/ui/src/app/metadata/domain/filter/NameIdFilterDefinition.js index ff16383e9..f092b5813 100644 --- a/ui/src/app/metadata/domain/filter/NameIdFilterDefinition.js +++ b/ui/src/app/metadata/domain/filter/NameIdFilterDefinition.js @@ -41,6 +41,13 @@ export const NameIDFilterWizard = { errors.nameIdFormatFilterTarget.value.addError('message.invalid-regex-pattern'); } } + + if (formData?.nameIdFormatFilterTarget?.nameIdFormatFilterTargetType === 'CONDITION_SCRIPT') { + const { nameIdFormatFilterTarget: { value } } = formData; + if (!value[0]) { + errors.nameIdFormatFilterTarget.value.addError('message.required-for-scripts'); + } + } return errors; } }, diff --git a/ui/src/app/metadata/hooks/api.js b/ui/src/app/metadata/hooks/api.js index f628e847d..78e98d3ba 100644 --- a/ui/src/app/metadata/hooks/api.js +++ b/ui/src/app/metadata/hooks/api.js @@ -109,7 +109,11 @@ export function useMetadataUpdater (path, current) { })); }); } - return Promise.resolve(req); + if (response.ok) { + return Promise.resolve(req); + } else { + return Promise.reject(req); + } } return { diff --git a/ui/src/app/metadata/new/NewFilter.js b/ui/src/app/metadata/new/NewFilter.js index 5fce5d31f..4aacb1574 100644 --- a/ui/src/app/metadata/new/NewFilter.js +++ b/ui/src/app/metadata/new/NewFilter.js @@ -9,12 +9,14 @@ import { MetadataForm } from '../hoc/MetadataFormContext'; import { MetadataSchema } from '../hoc/MetadataSchema'; import { useMetadataFilters, useMetadataFilterTypes } from '../hooks/api'; import { MetadataFilterTypeSelector } from '../wizard/MetadataFilterTypeSelector'; +import { createNotificationAction, NotificationTypes, useNotificationDispatcher } from '../../notifications/hoc/Notifications'; export function NewFilter() { const { id, section } = useParams(); const history = useHistory(); const types = useMetadataFilterTypes(); + const dispatch = useNotificationDispatcher(); const { post, response, loading } = useMetadataFilters(id, {}); @@ -22,9 +24,12 @@ export function NewFilter() { async function save(metadata) { - await post(``, metadata); + const resp = await post(``, metadata); if (response.ok) { + dispatch(createNotificationAction('Filter saved')); gotoDetail({ refresh: true }); + } else { + dispatch(createNotificationAction(resp.cause, NotificationTypes.DANGER)); } }; diff --git a/ui/src/app/metadata/view/EditFilter.js b/ui/src/app/metadata/view/EditFilter.js index bae6fa78e..5ac69fb8c 100644 --- a/ui/src/app/metadata/view/EditFilter.js +++ b/ui/src/app/metadata/view/EditFilter.js @@ -11,9 +11,12 @@ import { MetadataSchema } from '../hoc/MetadataSchema'; import { getMetadataPath, useMetadataUpdater } from '../hooks/api'; import { useMetadataFilterObject } from '../hoc/MetadataFilterSelector'; import API_BASE_PATH from '../../App.constant'; +import { createNotificationAction, NotificationTypes, useNotificationDispatcher } from '../../notifications/hoc/Notifications'; export function EditFilter() { + const dispatch = useNotificationDispatcher(); + const { id, filterId } = useParams(); const filter = useMetadataFilterObject(); const history = useHistory(); @@ -33,10 +36,11 @@ export function EditFilter() { function save(metadata) { setBlocking(false); - update(``, metadata).then(() => { + update(``, metadata).then((resp) => { + dispatch(createNotificationAction('Filter saved')); gotoDetail({ refresh: true }); - }).catch(() => { - window.location.reload(); + }).catch((error) => { + dispatch(createNotificationAction(error.cause, NotificationTypes.DANGER)); }); }; diff --git a/ui/src/app/metadata/view/MetadataUpload.js b/ui/src/app/metadata/view/MetadataUpload.js index d00be91fe..76ab28c5a 100644 --- a/ui/src/app/metadata/view/MetadataUpload.js +++ b/ui/src/app/metadata/view/MetadataUpload.js @@ -22,8 +22,6 @@ export function MetadataUpload() { async function save({serviceProviderName, file, url}) { - console.log(serviceProviderName, file); - setSaving(true); const f = file?.length > 0 ? file[0] : null; diff --git a/ui/src/theme/project/forms.scss b/ui/src/theme/project/forms.scss index b200b8191..8532aab7d 100644 --- a/ui/src/theme/project/forms.scss +++ b/ui/src/theme/project/forms.scss @@ -74,16 +74,21 @@ select.form-control:disabled { @include component-validation-state("invalid", $form-feedback-invalid-color); } +.editor { + tab-size: 4ch; + max-height: 400px; + overflow: auto; +} + .codearea { - white-space: pre-wrap; - resize: vertical; - font-family: monospace; + // white-space: pre-wrap; + // resize: vertical; min-height: 166px; margin-bottom: 0px; word-break: break-word; - &[contenteditable] { - overflow-x: hidden; - overflow-y: scroll; + + &.is-invalid > pre, &.is-invalid > textarea { + outline-color: transparent; } } @@ -123,3 +128,4 @@ mark { border-left: 0px; } } +