diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesController.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesController.java index 75b5be033..6adc4c95b 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesController.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesController.java @@ -2,12 +2,20 @@ import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Date; import javax.servlet.http.HttpServletRequest; +import org.apache.http.client.utils.DateUtils; import org.opensaml.core.xml.io.MarshallingException; import org.opensaml.saml.saml2.metadata.EntityDescriptor; 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; @@ -27,6 +35,10 @@ "/api/entities" }, // existing - included to break no existing code method = RequestMethod.GET) @Slf4j +/** +* EntitiesController is here to meet the requirements for this project being an MDQ. Despite similar logic to the +* EntitiesDescriptorController, the required endpoints that make this project an MDQ server are served by this controller. +*/ public class EntitiesController { @Autowired private EntityDescriptorService entityDescriptorService; @@ -45,7 +57,15 @@ public ResponseEntity getOne(final @PathVariable String entityId, HttpServlet return ResponseEntity.notFound().build(); } EntityDescriptorRepresentation entityDescriptorRepresentation = entityDescriptorService.createRepresentationFromDescriptor(entityDescriptor); - return ResponseEntity.ok(entityDescriptorRepresentation); + HttpHeaders headers = new HttpHeaders(); + headers.set("Last-Modified", formatModifiedDate(entityDescriptorRepresentation)); + return new ResponseEntity<>(entityDescriptorRepresentation, headers, HttpStatus.OK); + } + + private String formatModifiedDate(EntityDescriptorRepresentation entityDescriptorRepresentation) { + Instant instant = entityDescriptorRepresentation.getModifiedDateAsDate().toInstant(ZoneOffset.UTC); + Date date = Date.from(instant); + return DateUtils.formatDate(date, DateUtils.PATTERN_RFC1123); } @RequestMapping(value = "/{entityId:.*}", produces = "application/xml") @@ -56,7 +76,10 @@ public ResponseEntity getOneXml(final @PathVariable String entityId) throws M return ResponseEntity.notFound().build(); } final String xml = this.openSamlObjects.marshalToXmlString(entityDescriptor); - return ResponseEntity.ok(xml); + EntityDescriptorRepresentation entityDescriptorRepresentation = entityDescriptorService.createRepresentationFromDescriptor(entityDescriptor); + HttpHeaders headers = new HttpHeaders(); + headers.set("Last-Modified", formatModifiedDate(entityDescriptorRepresentation)); + return new ResponseEntity<>(xml, headers, HttpStatus.OK); } private EntityDescriptor getEntityDescriptor(final String entityId) throws ResolverException, UnsupportedEncodingException { diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/frontend/EntityDescriptorRepresentation.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/frontend/EntityDescriptorRepresentation.java index 2131696c4..32063271e 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/frontend/EntityDescriptorRepresentation.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/domain/frontend/EntityDescriptorRepresentation.java @@ -1,5 +1,6 @@ package edu.internet2.tier.shibboleth.admin.ui.domain.frontend; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -188,6 +189,12 @@ public void setCreatedDate(LocalDateTime createdDate) { public String getModifiedDate() { return modifiedDate != null ? modifiedDate.toString() : null; } + + @JsonIgnore + public LocalDateTime getModifiedDateAsDate() { + // we shouldn't have an ED without either modified or created date, so this is mostly for testing where data can be odd + return modifiedDate != null ? modifiedDate : createdDate != null ? createdDate : LocalDateTime.now(); + } public void setModifiedDate(LocalDateTime modifiedDate) { this.modifiedDate = modifiedDate; diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerIntegrationTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerIntegrationTests.groovy index 01a4ff060..bda1b4dfa 100644 --- a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerIntegrationTests.groovy +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerIntegrationTests.groovy @@ -1,14 +1,20 @@ package edu.internet2.tier.shibboleth.admin.ui.controller 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.security.service.UserService import groovy.json.JsonOutput import net.shibboleth.ext.spring.resource.ResourceHelper +import net.shibboleth.utilities.java.support.resolver.CriteriaSet + import org.joda.time.DateTime +import org.opensaml.core.criterion.EntityIdCriterion import org.opensaml.saml.metadata.resolver.ChainingMetadataResolver import org.opensaml.saml.metadata.resolver.MetadataResolver import org.opensaml.saml.metadata.resolver.filter.MetadataFilterChain import org.opensaml.saml.metadata.resolver.impl.FilesystemMetadataResolver import org.opensaml.saml.metadata.resolver.impl.ResourceBackedMetadataResolver +import org.spockframework.spring.SpringBean import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration @@ -35,11 +41,26 @@ class EntitiesControllerIntegrationTests extends Specification { @Autowired private WebTestClient webClient - /*def setup() { - // yeah, don't ask... this is just shenanigans - // The API is changed. Doesn't work anymore. Not sure if we need it here - this.webClient.webClient.uriBuilderFactory.encodingMode = DefaultUriBuilderFactory.EncodingMode.NONE - }*/ + def openSamlObjects = new OpenSamlObjects().with { + init() + it + } + + def resource = ResourceHelper.of(new ClassPathResource("/metadata/aggregate.xml")) + + def metadataResolver = new ResourceBackedMetadataResolver(resource).with { + it.id = 'test' + it.parserPool = openSamlObjects.parserPool + initialize() + it + } + + // This stub will spit out the results from the resolver instead of actually finding them in the DB + @SpringBean + EntityDescriptorRepository edr = Stub(EntityDescriptorRepository) { + findByEntityID("http://test.scaldingspoon.org/test1") >> metadataResolver.resolveSingle(new CriteriaSet(new EntityIdCriterion("http://test.scaldingspoon.org/test1"))) + findByEntityID("test") >> metadataResolver.resolveSingle(new CriteriaSet(new EntityIdCriterion("test"))) + } //todo review def "GET /api/entities returns the proper json"() { diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerTests.groovy index 380c84e4f..8f262b6a1 100644 --- a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerTests.groovy +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/EntitiesControllerTests.groovy @@ -5,11 +5,16 @@ import edu.internet2.tier.shibboleth.admin.ui.configuration.Internationalization import edu.internet2.tier.shibboleth.admin.ui.configuration.SearchConfiguration import edu.internet2.tier.shibboleth.admin.ui.configuration.TestConfiguration 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.security.service.UserService import edu.internet2.tier.shibboleth.admin.ui.service.JPAEntityDescriptorServiceImpl import edu.internet2.tier.shibboleth.admin.ui.service.JPAEntityServiceImpl import net.shibboleth.ext.spring.resource.ResourceHelper +import net.shibboleth.utilities.java.support.resolver.CriteriaSet + +import org.opensaml.core.criterion.EntityIdCriterion import org.opensaml.saml.metadata.resolver.impl.ResourceBackedMetadataResolver +import org.spockframework.spring.SpringBean import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest @@ -22,6 +27,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders import spock.lang.Specification import spock.lang.Subject +import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.* @@ -47,12 +53,19 @@ class EntitiesControllerTests extends Specification { @Autowired UserService userService - + + // This stub will spit out the results from the resolver instead of actually finding them in the DB + @SpringBean + EntityDescriptorRepository edr = Stub(EntityDescriptorRepository) { + findByEntityID("http://test.scaldingspoon.org/test1") >> metadataResolver.resolveSingle(new CriteriaSet(new EntityIdCriterion("http://test.scaldingspoon.org/test1"))) + findByEntityID("test") >> metadataResolver.resolveSingle(new CriteriaSet(new EntityIdCriterion("test"))) + } + @Subject def controller = new EntitiesController( openSamlObjects: openSamlObjects, entityDescriptorService: new JPAEntityDescriptorServiceImpl(openSamlObjects, new JPAEntityServiceImpl(openSamlObjects), userService), - metadataResolver: metadataResolver + entityDescriptorRepository: edr ) def mockMvc = MockMvcBuilders.standaloneSetup(controller).build() @@ -116,14 +129,21 @@ class EntitiesControllerTests extends Specification { "current":false } ''' + when: - def result = mockMvc.perform(get('/api/entities/http%3A%2F%2Ftest.scaldingspoon.org%2Ftest1')) + def result = mockMvc.perform(get('/entities/http%3A%2F%2Ftest.scaldingspoon.org%2Ftest1')) then: - def x = content() + // Response headers section 2.5 + // from the spec https://www.ietf.org/archive/id/draft-young-md-query-14.txt result.andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(content().json(expectedBody, false)) + .andExpect(header().exists(HttpHeaders.CONTENT_TYPE)) // MUST HAVE +// .andExpect(header().exists(HttpHeaders.CONTENT_LENGTH)) // SHOULD HAVE - should end up from etag filter, so skipped for test +// .andExpect(header().exists(HttpHeaders.CACHE_CONTROL)) // SHOULD HAVE - should be included by Spring Security +// .andExpect(header().exists(HttpHeaders.ETAG)) // MUST HAVE - is done by filter, so skipped for test + .andExpect(header().exists(HttpHeaders.LAST_MODIFIED)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedBody, false)) } def 'GET /entities/http%3A%2F%2Ftest.scaldingspoon.org%2Ftest1'() { @@ -153,6 +173,7 @@ class EntitiesControllerTests extends Specification { "current":false } ''' + when: def result = mockMvc.perform(get('/entities/http%3A%2F%2Ftest.scaldingspoon.org%2Ftest1')) @@ -163,8 +184,8 @@ class EntitiesControllerTests extends Specification { .andExpect(header().exists(HttpHeaders.CONTENT_TYPE)) // MUST HAVE // .andExpect(header().exists(HttpHeaders.CONTENT_LENGTH)) // SHOULD HAVE - should end up from etag filter, so skipped for test // .andExpect(header().exists(HttpHeaders.CACHE_CONTROL)) // SHOULD HAVE - should be included by Spring Security -// .andExpect(header().exists(HttpHeaders.LAST_MODIFIED)) // SHOULD HAVE - should end up from etag filter, so skipped for test // .andExpect(header().exists(HttpHeaders.ETAG)) // MUST HAVE - is done by filter, so skipped for test + .andExpect(header().exists(HttpHeaders.LAST_MODIFIED)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(expectedBody, false)) }