diff --git a/CHANGELOG.md b/CHANGELOG.md index 449568a6..cedbc0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Tests**: A JDBC URL `jdbc:h2:mem:testdb;MODE=NOT_A_MODE` is rejected with an SQLException that names the unknown mode. `POST /api/connections/test` returns HTTP 400 for that URL. `GET /api/connections/{id}/test` no longer repeats the same rejection ([#314](https://github.com/sitmun/sitmun-backend-core/issues/314)). - **Tests**: Database connection, role, territory, territory group type, and user repository tests use MockMvc on the shared context. They no longer boot an embedded server ([#316](https://github.com/sitmun/sitmun-backend-core/issues/316)). - **Tests**: `UserChecksServiceTest` calls `getWarnings` without a Spring context. `JsonWebTokenServiceTest` loads only `JsonWebTokenService` with a fixed clock. `SpatialReferenceSystemTest`, `UserPositionBusinessLogicTest`, and `UserPositionRepositoryDataRestTest` reuse an existing suite context. Password reset, MIA render security, language change, task literal enrollment, and database connection tests name why their context stays unique ([#315](https://github.com/sitmun/sitmun-backend-core/issues/315)). +- **Tests**: `TerritoryMembershipAssociationWriteTest` locks `PUT /api/territories/{id}/members` and `PUT /api/territories/{id}/memberOf` on `STM_GRP_TER`. `ApplicationRoleAssociationWriteTest` locks `PUT /api/applications/{id}/availableRoles` and `PUT /api/roles/{id}/applications` on `STM_APP_ROL`. Each side stays an owning association. Replacing one side leaves the other side's join row ([#297](https://github.com/sitmun/sitmun-backend-core/issues/297)). ### Security diff --git a/src/test/java/org/sitmun/domain/application/ApplicationRoleAssociationWriteTest.java b/src/test/java/org/sitmun/domain/application/ApplicationRoleAssociationWriteTest.java new file mode 100644 index 00000000..8934a04e --- /dev/null +++ b/src/test/java/org/sitmun/domain/application/ApplicationRoleAssociationWriteTest.java @@ -0,0 +1,184 @@ +package org.sitmun.domain.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.sitmun.test.URIConstants.APPLICATION_URI; +import static org.sitmun.test.URIConstants.APPLICATION_URI_AVAILABLE_ROLES; +import static org.sitmun.test.URIConstants.ROLE_APPLICATIONS_URI; +import static org.sitmun.test.URIConstants.ROLE_URI; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sitmun.domain.role.Role; +import org.sitmun.domain.role.RoleRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("Application role association writes") +class ApplicationRoleAssociationWriteTest { + + @Autowired private MockMvc mvc; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private RoleRepository roleRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List applications = new ArrayList<>(); + private final List roles = new ArrayList<>(); + + @AfterEach + void cleanup() { + for (Application application : applications) { + jdbcTemplate.update("DELETE FROM STM_APP_ROL WHERE ARO_APPID = ?", application.getId()); + } + for (Role role : roles) { + jdbcTemplate.update("DELETE FROM STM_APP_ROL WHERE ARO_ROLEID = ?", role.getId()); + } + applications.forEach(application -> applicationRepository.deleteById(application.getId())); + roles.forEach(role -> roleRepository.deleteById(role.getId())); + applications.clear(); + roles.clear(); + } + + @Test + @DisplayName("PUT availableRoles inserts one STM_APP_ROL row visible from both sides") + @WithMockUser(roles = "ADMIN") + void putAvailableRolesInsertsOneJoinRowVisibleFromBothSides() throws Exception { + Application application = application("app"); + Role role = role("role"); + + putAvailableRoles(application, role); + + assertThat(joinRows(application.getId(), role.getId())).isEqualTo(1); + assertAvailableRolesContains(application, role); + assertApplicationsContains(role, application); + } + + @Test + @DisplayName("PUT applications inserts the same STM_APP_ROL pair") + @WithMockUser(roles = "ADMIN") + void putApplicationsInsertsTheSamePair() throws Exception { + Application application = application("app"); + Role role = role("role"); + + putApplications(role, application); + + assertThat(joinRows(application.getId(), role.getId())).isEqualTo(1); + assertAvailableRolesContains(application, role); + assertApplicationsContains(role, application); + } + + @Test + @DisplayName("Replacing availableRoles leaves the applications row") + @WithMockUser(roles = "ADMIN") + void replacingAvailableRolesLeavesTheApplicationsRow() throws Exception { + Application center = application("center"); + Application other = application("other"); + Role childRole = role("child"); + Role replacement = role("next"); + + putApplications(childRole, other); + putAvailableRoles(center, childRole); + + putAvailableRoles(center, replacement); + + assertThat(joinRows(other.getId(), childRole.getId())).isEqualTo(1); + assertThat(joinRows(center.getId(), replacement.getId())).isEqualTo(1); + assertThat(joinRows(center.getId(), childRole.getId())).isZero(); + assertApplicationsContains(childRole, other); + assertAvailableRolesContains(center, replacement); + } + + @Test + @DisplayName("Replacing applications leaves the availableRoles row") + @WithMockUser(roles = "ADMIN") + void replacingApplicationsLeavesTheAvailableRolesRow() throws Exception { + Application center = application("center"); + Application replacement = application("next"); + Role childRole = role("child"); + Role parentRole = role("parent"); + + putAvailableRoles(center, childRole); + putApplications(parentRole, center); + + putApplications(parentRole, replacement); + + assertThat(joinRows(center.getId(), childRole.getId())).isEqualTo(1); + assertThat(joinRows(replacement.getId(), parentRole.getId())).isEqualTo(1); + assertThat(joinRows(center.getId(), parentRole.getId())).isZero(); + assertAvailableRolesContains(center, childRole); + assertApplicationsContains(parentRole, replacement); + } + + private Application application(String label) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + Application application = + applicationRepository.save( + Application.builder() + .name(label + "-" + suffix) + .type("I") + .jspTemplate("") + .createdDate(Date.from(Instant.now())) + .build()); + applications.add(application); + return application; + } + + private Role role(String label) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + Role role = roleRepository.save(Role.builder().name(label + "-" + suffix).build()); + roles.add(role); + return role; + } + + private void putAvailableRoles(Application application, Role role) throws Exception { + mvc.perform( + put(APPLICATION_URI_AVAILABLE_ROLES, application.getId()) + .content(ROLE_URI.replace("{0}", role.getId().toString())) + .contentType("text/uri-list")) + .andExpect(status().isNoContent()); + } + + private void putApplications(Role role, Application application) throws Exception { + mvc.perform( + put(ROLE_APPLICATIONS_URI, role.getId()) + .content(APPLICATION_URI.replace("{0}", application.getId().toString())) + .contentType("text/uri-list")) + .andExpect(status().isNoContent()); + } + + private void assertAvailableRolesContains(Application application, Role role) throws Exception { + mvc.perform(get(APPLICATION_URI_AVAILABLE_ROLES, application.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.roles[*].name", hasItem(role.getName()))); + } + + private void assertApplicationsContains(Role role, Application application) throws Exception { + mvc.perform(get(ROLE_APPLICATIONS_URI, role.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.applications[*].name", hasItem(application.getName()))); + } + + private Integer joinRows(Integer applicationId, Integer roleId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM STM_APP_ROL WHERE ARO_APPID = ? AND ARO_ROLEID = ?", + Integer.class, + applicationId, + roleId); + } +} diff --git a/src/test/java/org/sitmun/domain/territory/TerritoryMembershipAssociationWriteTest.java b/src/test/java/org/sitmun/domain/territory/TerritoryMembershipAssociationWriteTest.java new file mode 100644 index 00000000..72f7bc1f --- /dev/null +++ b/src/test/java/org/sitmun/domain/territory/TerritoryMembershipAssociationWriteTest.java @@ -0,0 +1,160 @@ +package org.sitmun.domain.territory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.sitmun.test.URIConstants.TERRITORY_URI; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("Territory membership association writes") +class TerritoryMembershipAssociationWriteTest { + + private static final String MEMBERS_URI = TERRITORY_URI + "/members"; + private static final String MEMBER_OF_URI = TERRITORY_URI + "/memberOf"; + + @Autowired private MockMvc mvc; + @Autowired private TerritoryRepository territoryRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private final List territories = new ArrayList<>(); + + @AfterEach + void cleanup() { + for (Territory territory : territories) { + jdbcTemplate.update( + "DELETE FROM STM_GRP_TER WHERE GTE_TERID = ? OR GTE_TERMID = ?", + territory.getId(), + territory.getId()); + } + territories.forEach(territory -> territoryRepository.deleteById(territory.getId())); + territories.clear(); + } + + @Test + @DisplayName("PUT members inserts one STM_GRP_TER row visible from both sides") + @WithMockUser(roles = "ADMIN") + void putMembersInsertsOneJoinRowVisibleFromBothSides() throws Exception { + Territory parent = territory("parent"); + Territory child = territory("child"); + + putAssociation(MEMBERS_URI, parent.getId(), child); + + assertThat(joinRows(parent.getId(), child.getId())).isEqualTo(1); + assertMembersContains(parent, child); + assertMemberOfContains(child, parent); + } + + @Test + @DisplayName("PUT memberOf inserts the same STM_GRP_TER pair") + @WithMockUser(roles = "ADMIN") + void putMemberOfInsertsTheSamePair() throws Exception { + Territory parent = territory("parent"); + Territory child = territory("child"); + + putAssociation(MEMBER_OF_URI, child.getId(), parent); + + assertThat(joinRows(parent.getId(), child.getId())).isEqualTo(1); + assertMembersContains(parent, child); + assertMemberOfContains(child, parent); + } + + @Test + @DisplayName("Replacing members leaves the member-of row") + @WithMockUser(roles = "ADMIN") + void replacingMembersLeavesTheMemberOfRow() throws Exception { + Territory center = territory("center"); + Territory child = territory("child"); + Territory parent = territory("parent"); + Territory replacement = territory("next"); + + putAssociation(MEMBERS_URI, center.getId(), child); + putAssociation(MEMBER_OF_URI, center.getId(), parent); + + putAssociation(MEMBERS_URI, center.getId(), replacement); + + assertThat(joinRows(parent.getId(), center.getId())).isEqualTo(1); + assertThat(joinRows(center.getId(), replacement.getId())).isEqualTo(1); + assertThat(joinRows(center.getId(), child.getId())).isZero(); + assertMemberOfContains(center, parent); + assertMembersContains(center, replacement); + } + + @Test + @DisplayName("Replacing memberOf leaves the members row") + @WithMockUser(roles = "ADMIN") + void replacingMemberOfLeavesTheMembersRow() throws Exception { + Territory center = territory("center"); + Territory child = territory("child"); + Territory parent = territory("parent"); + Territory replacement = territory("next"); + + putAssociation(MEMBERS_URI, center.getId(), child); + putAssociation(MEMBER_OF_URI, center.getId(), parent); + + putAssociation(MEMBER_OF_URI, center.getId(), replacement); + + assertThat(joinRows(center.getId(), child.getId())).isEqualTo(1); + assertThat(joinRows(replacement.getId(), center.getId())).isEqualTo(1); + assertThat(joinRows(parent.getId(), center.getId())).isZero(); + assertMembersContains(center, child); + assertMemberOfContains(center, replacement); + } + + private Territory territory(String label) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + Territory territory = + territoryRepository.save( + Territory.builder() + .name(label + "-" + suffix) + .code(label + "-" + suffix) + .blocked(false) + .build()); + territories.add(territory); + return territory; + } + + private void putAssociation(String uri, Integer ownerId, Territory target) throws Exception { + mvc.perform( + put(uri, ownerId) + .content(TERRITORY_URI.replace("{0}", target.getId().toString())) + .contentType("text/uri-list")) + .andExpect(status().isNoContent()); + } + + private void assertMembersContains(Territory owner, Territory member) throws Exception { + mvc.perform(get(MEMBERS_URI, owner.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.territories[*].name", hasItem(member.getName()))); + } + + private void assertMemberOfContains(Territory member, Territory parent) throws Exception { + mvc.perform(get(MEMBER_OF_URI, member.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.territories[*].name", hasItem(parent.getName()))); + } + + private Integer joinRows(Integer territoryId, Integer memberId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM STM_GRP_TER WHERE GTE_TERID = ? AND GTE_TERMID = ?", + Integer.class, + territoryId, + memberId); + } +}