diff --git a/src/main/java/org/etsi/osl/tmf/pcm620/api/HubApiController.java b/src/main/java/org/etsi/osl/tmf/pcm620/api/HubApiController.java index 55177431990a0cec5dcf93349f54e941558159d7..b15db91667abb9079a912edb82a5c803a9b1fed2 100644 --- a/src/main/java/org/etsi/osl/tmf/pcm620/api/HubApiController.java +++ b/src/main/java/org/etsi/osl/tmf/pcm620/api/HubApiController.java @@ -22,21 +22,38 @@ package org.etsi.osl.tmf.pcm620.api; import java.util.Optional; import com.fasterxml.jackson.databind.ObjectMapper; - +import org.etsi.osl.tmf.pcm620.model.EventSubscription; +import org.etsi.osl.tmf.pcm620.model.EventSubscriptionInput; +import org.etsi.osl.tmf.pcm620.reposervices.EventSubscriptionRepoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; @jakarta.annotation.Generated(value = "io.swagger.codegen.languages.SpringCodegen", date = "2019-10-19T00:15:57.249+03:00") @Controller("HubApiController620") @RequestMapping("/productCatalogManagement/v4/") public class HubApiController implements HubApi { + private static final Logger log = LoggerFactory.getLogger(HubApiController.class); + private final ObjectMapper objectMapper; private final HttpServletRequest request; + @Autowired + EventSubscriptionRepoService eventSubscriptionRepoService; + @org.springframework.beans.factory.annotation.Autowired public HubApiController(ObjectMapper objectMapper, HttpServletRequest request) { this.objectMapper = objectMapper; @@ -53,4 +70,36 @@ public class HubApiController implements HubApi { return Optional.ofNullable(request); } + @Override + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public ResponseEntity registerListener(@Parameter(description = "Data containing the callback endpoint to deliver the information", required = true) @Valid @RequestBody EventSubscriptionInput data) { + try { + EventSubscription eventSubscription = eventSubscriptionRepoService.addEventSubscription(data); + return new ResponseEntity<>(eventSubscription, HttpStatus.CREATED); + } catch (IllegalArgumentException e) { + log.error("Invalid input for listener registration: {}", e.getMessage()); + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error registering listener", e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Override + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public ResponseEntity unregisterListener(@Parameter(description = "The id of the registered listener", required = true) @PathVariable("id") String id) { + try { + EventSubscription existing = eventSubscriptionRepoService.findById(id); + if (existing == null) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + eventSubscriptionRepoService.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + log.error("Error unregistering listener with id: " + id, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + } diff --git a/src/main/java/org/etsi/osl/tmf/pcm620/repo/EventSubscriptionRepository.java b/src/main/java/org/etsi/osl/tmf/pcm620/repo/EventSubscriptionRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..117892dc8416329a91816b469086041030602919 --- /dev/null +++ b/src/main/java/org/etsi/osl/tmf/pcm620/repo/EventSubscriptionRepository.java @@ -0,0 +1,32 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.tmf.api + * %% + * Copyright (C) 2019 - 2021 openslice.io + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.tmf.pcm620.repo; + +import java.util.Optional; +import org.etsi.osl.tmf.pcm620.model.EventSubscription; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventSubscriptionRepository extends CrudRepository, PagingAndSortingRepository { + + Optional findById(String id); +} \ No newline at end of file diff --git a/src/main/java/org/etsi/osl/tmf/pcm620/reposervices/EventSubscriptionRepoService.java b/src/main/java/org/etsi/osl/tmf/pcm620/reposervices/EventSubscriptionRepoService.java new file mode 100644 index 0000000000000000000000000000000000000000..8fc9caad8102825fb4f48d8f46570d0bc4eb2b11 --- /dev/null +++ b/src/main/java/org/etsi/osl/tmf/pcm620/reposervices/EventSubscriptionRepoService.java @@ -0,0 +1,65 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.tmf.api + * %% + * Copyright (C) 2019 - 2021 openslice.io + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.tmf.pcm620.reposervices; + +import java.util.Optional; +import java.util.UUID; + +import org.etsi.osl.tmf.pcm620.model.EventSubscription; +import org.etsi.osl.tmf.pcm620.model.EventSubscriptionInput; +import org.etsi.osl.tmf.pcm620.repo.EventSubscriptionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.validation.Valid; + +@Service +@Transactional +public class EventSubscriptionRepoService { + + @Autowired + EventSubscriptionRepository eventSubscriptionRepo; + + public EventSubscription addEventSubscription(@Valid EventSubscriptionInput eventSubscriptionInput) { + if (eventSubscriptionInput.getCallback() == null || eventSubscriptionInput.getCallback().trim().isEmpty()) { + throw new IllegalArgumentException("Callback URL is required and cannot be empty"); + } + + EventSubscription eventSubscription = new EventSubscription(); + eventSubscription.setId(UUID.randomUUID().toString()); + eventSubscription.setCallback(eventSubscriptionInput.getCallback()); + eventSubscription.setQuery(eventSubscriptionInput.getQuery()); + + return this.eventSubscriptionRepo.save(eventSubscription); + } + + public EventSubscription findById(String id) { + Optional optionalEventSubscription = this.eventSubscriptionRepo.findById(id); + return optionalEventSubscription.orElse(null); + } + + public void deleteById(String id) { + Optional optionalEventSubscription = this.eventSubscriptionRepo.findById(id); + if (optionalEventSubscription.isPresent()) { + this.eventSubscriptionRepo.delete(optionalEventSubscription.get()); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/etsi/osl/tmf/pim637/api/HubApiController.java b/src/main/java/org/etsi/osl/tmf/pim637/api/HubApiController.java index 96129c8d13131e6c601ca1a826feaff0434177e2..0b1b6f4505c98766659a37185835660871ed5b0f 100644 --- a/src/main/java/org/etsi/osl/tmf/pim637/api/HubApiController.java +++ b/src/main/java/org/etsi/osl/tmf/pim637/api/HubApiController.java @@ -1,6 +1,10 @@ package org.etsi.osl.tmf.pim637.api; import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + import jakarta.servlet.http.HttpServletRequest; import com.fasterxml.jackson.databind.ObjectMapper; import org.etsi.osl.tmf.pim637.model.EventSubscription; diff --git a/src/test/java/org/etsi/osl/services/api/pcm620/HubApiControllerTest.java b/src/test/java/org/etsi/osl/services/api/pcm620/HubApiControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..072e514f125eca7dde05aa31585e1e73f2e3bd26 --- /dev/null +++ b/src/test/java/org/etsi/osl/services/api/pcm620/HubApiControllerTest.java @@ -0,0 +1,245 @@ +package org.etsi.osl.services.api.pcm620; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; + +import org.apache.commons.io.IOUtils; +import org.etsi.osl.tmf.JsonUtils; +import org.etsi.osl.tmf.OpenAPISpringBoot; +import org.etsi.osl.tmf.pcm620.model.EventSubscription; +import org.etsi.osl.tmf.pcm620.model.EventSubscriptionInput; +import org.etsi.osl.tmf.pcm620.reposervices.EventSubscriptionRepoService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@RunWith(SpringRunner.class) +@Transactional +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, classes = OpenAPISpringBoot.class) +@AutoConfigureMockMvc +@ActiveProfiles("testing") +@AutoConfigureTestDatabase +public class HubApiControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @Autowired + private EventSubscriptionRepoService eventSubscriptionRepoService; + + @Autowired + private ObjectMapper objectMapper; + + @Before + public void setup() { + mvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testRegisterListener() throws Exception { + File resourceSpecFile = new File("src/test/resources/testPCM620EventSubscriptionInput.json"); + InputStream in = new FileInputStream(resourceSpecFile); + String eventSubscriptionInputString = IOUtils.toString(in, "UTF-8"); + EventSubscriptionInput eventSubscriptionInput = JsonUtils.toJsonObj(eventSubscriptionInputString, EventSubscriptionInput.class); + + MvcResult result = mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(eventSubscriptionInput))) + .andExpect(status().isCreated()) + .andExpect(content().contentType("application/json;charset=utf-8")) + .andExpect(jsonPath("$.callback").value("http://localhost:8080/callback")) + .andExpect(jsonPath("$.query").value("productOffering.create,productOffering.delete")) + .andExpect(jsonPath("$.id").exists()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + EventSubscription createdSubscription = objectMapper.readValue(responseBody, EventSubscription.class); + + // Verify the subscription was actually saved to the database + EventSubscription retrievedSubscription = eventSubscriptionRepoService.findById(createdSubscription.getId()); + assert retrievedSubscription != null; + assert retrievedSubscription.getCallback().equals("http://localhost:8080/callback"); + assert retrievedSubscription.getQuery().equals("productOffering.create,productOffering.delete"); + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testRegisterListenerWithoutAcceptHeader() throws Exception { + File resourceSpecFile = new File("src/test/resources/testPCM620EventSubscriptionInput.json"); + InputStream in = new FileInputStream(resourceSpecFile); + String eventSubscriptionInputString = IOUtils.toString(in, "UTF-8"); + EventSubscriptionInput eventSubscriptionInput = JsonUtils.toJsonObj(eventSubscriptionInputString, EventSubscriptionInput.class); + + mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(eventSubscriptionInput))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.callback").value("http://localhost:8080/callback")) + .andExpect(jsonPath("$.query").value("productOffering.create,productOffering.delete")) + .andExpect(jsonPath("$.id").exists()); + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testUnregisterListener() throws Exception { + // First, create a subscription + EventSubscriptionInput input = new EventSubscriptionInput(); + input.setCallback("http://localhost:8080/callback"); + input.setQuery("test.event"); + + EventSubscription created = eventSubscriptionRepoService.addEventSubscription(input); + String subscriptionId = created.getId(); + + // Verify the subscription exists + assert eventSubscriptionRepoService.findById(subscriptionId) != null; + + // Delete the subscription + mvc.perform(MockMvcRequestBuilders.delete("/productCatalogManagement/v4/hub/" + subscriptionId) + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + + // Verify the subscription was deleted + assert eventSubscriptionRepoService.findById(subscriptionId) == null; + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testUnregisterNonExistentListener() throws Exception { + String nonExistentId = "non-existent-id"; + + mvc.perform(MockMvcRequestBuilders.delete("/productCatalogManagement/v4/hub/" + nonExistentId) + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testRegisterListenerWithInvalidData() throws Exception { + // Test with missing required callback field + EventSubscriptionInput invalidInput = new EventSubscriptionInput(); + invalidInput.setQuery("test.event"); + // callback is missing, which is required + + mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(invalidInput))) + .andExpect(status().isBadRequest()); + } + + @WithMockUser(username = "osadmin", roles = {"ADMIN"}) + @Test + public void testRegisterListenerWithEmptyCallback() throws Exception { + EventSubscriptionInput invalidInput = new EventSubscriptionInput(); + invalidInput.setCallback(""); + invalidInput.setQuery("test.event"); + + mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(invalidInput))) + .andExpect(status().isBadRequest()); + } + + @WithMockUser(username = "user", roles = {"USER"}) + @Test + public void testRegisterListenerUnauthorized() throws Exception { + File resourceSpecFile = new File("src/test/resources/testPCM620EventSubscriptionInput.json"); + InputStream in = new FileInputStream(resourceSpecFile); + String eventSubscriptionInputString = IOUtils.toString(in, "UTF-8"); + EventSubscriptionInput eventSubscriptionInput = JsonUtils.toJsonObj(eventSubscriptionInputString, EventSubscriptionInput.class); + + mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(eventSubscriptionInput))) + .andExpect(status().isForbidden()); + } + + @WithMockUser(username = "user", roles = {"USER"}) + @Test + public void testUnregisterListenerUnauthorized() throws Exception { + // First create a subscription as admin + EventSubscriptionInput input = new EventSubscriptionInput(); + input.setCallback("http://localhost:8080/callback"); + input.setQuery("test.event"); + EventSubscription created = eventSubscriptionRepoService.addEventSubscription(input); + String subscriptionId = created.getId(); + + // Try to delete as regular user (should be forbidden) + mvc.perform(MockMvcRequestBuilders.delete("/productCatalogManagement/v4/hub/" + subscriptionId) + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + // Verify the subscription still exists + assert eventSubscriptionRepoService.findById(subscriptionId) != null; + } + + @Test + public void testRegisterListenerUnauthenticated() throws Exception { + File resourceSpecFile = new File("src/test/resources/testPCM620EventSubscriptionInput.json"); + InputStream in = new FileInputStream(resourceSpecFile); + String eventSubscriptionInputString = IOUtils.toString(in, "UTF-8"); + EventSubscriptionInput eventSubscriptionInput = JsonUtils.toJsonObj(eventSubscriptionInputString, EventSubscriptionInput.class); + + mvc.perform(MockMvcRequestBuilders.post("/productCatalogManagement/v4/hub") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.toJson(eventSubscriptionInput))) + .andExpect(status().isUnauthorized()); + } + + @Test + public void testUnregisterListenerUnauthenticated() throws Exception { + String testId = "test-subscription-id"; + + mvc.perform(MockMvcRequestBuilders.delete("/productCatalogManagement/v4/hub/" + testId) + .with(SecurityMockMvcRequestPostProcessors.csrf()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/src/test/resources/testPCM620EventSubscriptionInput.json b/src/test/resources/testPCM620EventSubscriptionInput.json new file mode 100644 index 0000000000000000000000000000000000000000..e6aa9f08826bd35327ef3843a4194406953ac482 --- /dev/null +++ b/src/test/resources/testPCM620EventSubscriptionInput.json @@ -0,0 +1,4 @@ +{ + "callback": "http://localhost:8080/callback", + "query": "productOffering.create,productOffering.delete" +} \ No newline at end of file