diff --git a/pom.xml b/pom.xml index ab9f184fe..232b517d1 100644 --- a/pom.xml +++ b/pom.xml @@ -208,13 +208,7 @@ - - - com.google.guava - guava - 29.0-jre - org.springframework.vault spring-vault-dependencies diff --git a/src/main/client/app/pages/users/organizations.vue b/src/main/client/app/pages/users/organizations.vue new file mode 100644 index 000000000..1f3c199e6 --- /dev/null +++ b/src/main/client/app/pages/users/organizations.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/main/client/app/pages/users/user-edit.vue b/src/main/client/app/pages/users/user-edit.vue new file mode 100644 index 000000000..005fcf95b --- /dev/null +++ b/src/main/client/app/pages/users/user-edit.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/main/client/app/pages/users/user-name.vue b/src/main/client/app/pages/users/user-name.vue new file mode 100644 index 000000000..abaaf4ca4 --- /dev/null +++ b/src/main/client/app/pages/users/user-name.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/main/client/app/pages/users/user-new.vue b/src/main/client/app/pages/users/user-new.vue new file mode 100644 index 000000000..c870b12e2 --- /dev/null +++ b/src/main/client/app/pages/users/user-new.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/main/client/app/pages/users/user-type.vue b/src/main/client/app/pages/users/user-type.vue new file mode 100644 index 000000000..8e0d0d54c --- /dev/null +++ b/src/main/client/app/pages/users/user-type.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/main/client/app/pages/users/users-routes.js b/src/main/client/app/pages/users/users-routes.js index 4a705e5ed..172959334 100644 --- a/src/main/client/app/pages/users/users-routes.js +++ b/src/main/client/app/pages/users/users-routes.js @@ -9,6 +9,38 @@ const usersRoutes = [ title: 'Gaia - Users', }, }, + { + path: '/users/new', + name: 'new_user', + component: () => import(/* webpackChunkName: "chunk-users" */ '@/pages/users/user-new.vue'), + meta: { + authorities: ['ROLE_ADMIN'], + breadcrumb: [{ text: 'Users' }], + title: 'Gaia - New User', + }, + }, + { + path: '/users/:username', + name: 'user_edition', + component: () => import(/* webpackChunkName: "chunk-users" */ '@/pages/users/user-edit.vue'), + meta: { + authorities: ['ROLE_ADMIN'], + breadcrumb: [{ text: 'Users', to: { name: 'users' } }, { text: 'User edition' }], + title: 'Gaia - Edit User', + }, + props: true, + }, + { + path: '/organizations', + name: 'organizations', + component: () => import(/* webpackChunkName: "chunk-users" */ '@/pages/users/organizations.vue'), + meta: { + authorities: ['ROLE_ADMIN'], + breadcrumb: [{ text: 'Organizations' }], + title: 'Gaia - Organization', + }, + props: true, + }, ]; export default usersRoutes; diff --git a/src/main/client/app/pages/users/users.vue b/src/main/client/app/pages/users/users.vue index e055ec6d1..6b1a0a4d7 100644 --- a/src/main/client/app/pages/users/users.vue +++ b/src/main/client/app/pages/users/users.vue @@ -1,27 +1,43 @@ @@ -32,15 +48,18 @@ } from '@/shared/api/users-api'; import { getTeams } from '@/shared/api/teams-api'; import { displayNotification } from '@/shared/services/modal-service'; + import AppUserName from '@/pages/users/user-name.vue'; export default { name: 'AppUsers', + components: { AppUserName }, data: () => ({ teams: [], users: [], fields: [ { key: 'username', label: 'User', sortable: true }, - { key: 'team', sortable: true }, + { key: 'team.id', label: 'Team', sortable: true }, + { key: 'edit' }, ], }), async created() { diff --git a/src/main/client/app/shared/api/teams-api.js b/src/main/client/app/shared/api/teams-api.js index 55bab4c35..0a349b55a 100644 --- a/src/main/client/app/shared/api/teams-api.js +++ b/src/main/client/app/shared/api/teams-api.js @@ -1,3 +1,7 @@ import axios from 'axios'; export const getTeams = async () => axios.get('/api/teams'); + +export const deleteOrganization = async (organizationId) => axios.delete(`/api/teams/${organizationId}`); + +export const createOrganization = async (organization) => axios.post('/api/teams', organization); diff --git a/src/main/client/app/shared/api/users-api.js b/src/main/client/app/shared/api/users-api.js index b3376ff9b..31013951b 100644 --- a/src/main/client/app/shared/api/users-api.js +++ b/src/main/client/app/shared/api/users-api.js @@ -2,4 +2,15 @@ import axios from 'axios'; export const getUsers = async () => axios.get('/api/users'); -export const updateUser = async (user) => axios.put(`/api/users/${user.id}`, user); +export const getUser = async (username) => axios.get(`/api/users/${username}`); + +export const updateUser = async (user) => axios.put(`/api/users/${user.username}`, user); + +export const changeUserPassword = async (username, password) => axios.put( + `/api/users/${username}/password`, + `password=${password}`, +); + +export const createUser = async (user) => axios.post('/api/users', user); + +export const deleteUser = async (userId) => axios.delete(`/api/users/${userId}`); diff --git a/src/main/client/app/shared/components/sidebar/side-bar-links.vue b/src/main/client/app/shared/components/sidebar/side-bar-links.vue index 890e6844a..7cb599776 100644 --- a/src/main/client/app/shared/components/sidebar/side-bar-links.vue +++ b/src/main/client/app/shared/components/sidebar/side-bar-links.vue @@ -44,6 +44,9 @@ { route: 'users', icon: 'user-friends', class: 'yellow_color', title: 'Users', roles: ['ROLE_ADMIN'], }, + { + route: 'organizations', icon: 'sitemap', class: 'yellow_color', title: 'Organizations', roles: ['ROLE_ADMIN'], + }, ], }), computed: { diff --git a/src/main/client/app/shared/config/bootstrap-vue-config.js b/src/main/client/app/shared/config/bootstrap-vue-config.js index 4579fad00..4797f6232 100644 --- a/src/main/client/app/shared/config/bootstrap-vue-config.js +++ b/src/main/client/app/shared/config/bootstrap-vue-config.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { + AlertPlugin, BadgePlugin, BreadcrumbPlugin, ButtonPlugin, @@ -27,6 +28,7 @@ import { export default { init: () => { + Vue.use(AlertPlugin); Vue.use(BadgePlugin); Vue.use(BreadcrumbPlugin); Vue.use(ButtonPlugin); diff --git a/src/main/client/app/shared/config/fontawesome-config.js b/src/main/client/app/shared/config/fontawesome-config.js index 35b865e6f..21f00e6eb 100644 --- a/src/main/client/app/shared/config/fontawesome-config.js +++ b/src/main/client/app/shared/config/fontawesome-config.js @@ -27,6 +27,7 @@ import { faRocket, faSave, faSignOutAlt, + faSitemap, faStarOfLife, faStop, faStopCircle, @@ -35,6 +36,9 @@ import { faTag, faUpload, faUser, + faUserAlt, + faUserMinus, + faUserPlus, faUserFriends, faUserShield, } from '@fortawesome/free-solid-svg-icons'; @@ -59,6 +63,9 @@ export default { faDollarSign, faEdit, faUser, + faUserAlt, + faUserMinus, + faUserPlus, faLock, faGithub, faGitlab, @@ -84,6 +91,7 @@ export default { faRocket, faCaretSquareUp, faSave, + faSitemap, faStopCircle, faUpload, faUserShield, diff --git a/src/main/java/io/gaia_app/config/security/SecurityConfig.kt b/src/main/java/io/gaia_app/config/security/SecurityConfig.kt index 7fbfc6b99..2a823649a 100644 --- a/src/main/java/io/gaia_app/config/security/SecurityConfig.kt +++ b/src/main/java/io/gaia_app/config/security/SecurityConfig.kt @@ -1,13 +1,17 @@ package io.gaia_app.config.security +import io.gaia_app.teams.repository.UserRepository import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.csrf.CookieCsrfTokenRepository @@ -19,7 +23,8 @@ class SecurityConfig( val failureHandler: FailureHandler, val logoutSuccessHandler: LogoutSuccessHandler, val accessDeniedHandler: AccessDeniedHandler, - val authenticationEntryPoint: AuthenticationEntryPoint) : WebSecurityConfigurerAdapter() { + val authenticationEntryPoint: AuthenticationEntryPoint, + val userRepository: UserRepository) : WebSecurityConfigurerAdapter() { @Value("\${gaia.admin-password:admin123}") private val adminPassword: String? = null @@ -74,15 +79,21 @@ class SecurityConfig( // @formatter:on } - public override fun configure(auth: AuthenticationManagerBuilder) { - // @formatter:off - auth - .inMemoryAuthentication() - // configure default admin user - .withUser("admin").password(bcrypt().encode(adminPassword)).authorities("ROLE_ADMIN", "ROLE_USER") - .and() - .withUser("user").password(bcrypt().encode("user123")).authorities("ROLE_USER") - // @formatter:on + @Bean + override fun userDetailsService(): UserDetailsService { + return UserDetailsService { + username:String -> userRepository.findById(username) + .map { User.builder().username(it.username).password(it.password).authorities(it.toAuthorities()).build() } + .orElseThrow() + } } } + +fun io.gaia_app.teams.User.toAuthorities(): List { + return if (this.isAdmin) { + listOf(SimpleGrantedAuthority("ROLE_ADMIN"), SimpleGrantedAuthority("ROLE_USER")) + } else { + listOf(SimpleGrantedAuthority("ROLE_USER")) + } +} diff --git a/src/main/java/io/gaia_app/teams/UserService.kt b/src/main/java/io/gaia_app/teams/UserService.kt new file mode 100644 index 000000000..fb5a94e65 --- /dev/null +++ b/src/main/java/io/gaia_app/teams/UserService.kt @@ -0,0 +1,56 @@ +package io.gaia_app.teams + +import io.gaia_app.teams.repository.UserRepository +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + +interface UserService { + + fun findById(username: String): User + + fun findAll(): List + + fun create(user:User): User + + fun update(user:User): User + + fun changeUserPassword(username: String, newPassword: String): User + + fun deleteUser(username: String) +} + +@Service +class UserServiceImpl(val userRepository: UserRepository, val passwordEncoder: PasswordEncoder):UserService{ + + override fun findById(username: String): User = userRepository.findById(username).orElseThrow() + + override fun findAll(): List = userRepository.findAll() + + override fun create(user: User): User { + // encode password before saving to database + user.password = passwordEncoder.encode(user.password) + return userRepository.save(user) + } + + override fun update(user: User): User { + // reload original user + val originalUser = userRepository.findById(user.username).orElseThrow() + + // copying password & oauth data to keep them + user.password = originalUser.password + user.oAuth2User = originalUser.oAuth2User + + return userRepository.save(user) + } + + override fun changeUserPassword(username: String, newPassword: String): User { + val user = userRepository.findById(username).orElseThrow() + user.password = passwordEncoder.encode(newPassword) + + return userRepository.save(user) + } + + override fun deleteUser(username: String) { + userRepository.deleteById(username) + } +} diff --git a/src/main/java/io/gaia_app/teams/bo.kt b/src/main/java/io/gaia_app/teams/bo.kt index 0ce04f4ff..b23b68a8f 100644 --- a/src/main/java/io/gaia_app/teams/bo.kt +++ b/src/main/java/io/gaia_app/teams/bo.kt @@ -1,6 +1,8 @@ package io.gaia_app.teams +import com.fasterxml.jackson.annotation.JsonBackReference import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.DBRef @@ -16,10 +18,12 @@ data class User( @Id val username: String, @DBRef val team: Team?) { - val isAdmin: Boolean - get() = "admin" == this.username + var isAdmin: Boolean = false var oAuth2User: OAuth2User? = null + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + var password: String? = null } /** diff --git a/src/main/java/io/gaia_app/teams/controller/TeamsRestController.java b/src/main/java/io/gaia_app/teams/controller/TeamsRestController.java index 2f177691e..48818c585 100644 --- a/src/main/java/io/gaia_app/teams/controller/TeamsRestController.java +++ b/src/main/java/io/gaia_app/teams/controller/TeamsRestController.java @@ -2,11 +2,9 @@ import io.gaia_app.teams.Team; import io.gaia_app.teams.repository.TeamRepository; -import io.gaia_app.teams.repository.TeamRepository; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -26,4 +24,16 @@ public List teams(){ return this.teamRepository.findAll(); } + @PostMapping + @Secured("ROLE_ADMIN") + public Team createOrganization(@RequestBody Team organization){ + return this.teamRepository.save(organization); + } + + @DeleteMapping("/{organizationId}") + @Secured("ROLE_ADMIN") + public void deleteOrganization(@PathVariable String organizationId){ + this.teamRepository.deleteById(organizationId); + } + } diff --git a/src/main/java/io/gaia_app/teams/controller/UsersRestController.java b/src/main/java/io/gaia_app/teams/controller/UsersRestController.java index c26f46b11..6dcf82dd3 100644 --- a/src/main/java/io/gaia_app/teams/controller/UsersRestController.java +++ b/src/main/java/io/gaia_app/teams/controller/UsersRestController.java @@ -1,8 +1,7 @@ package io.gaia_app.teams.controller; import io.gaia_app.teams.User; -import io.gaia_app.teams.repository.UserRepository; -import io.gaia_app.teams.repository.UserRepository; +import io.gaia_app.teams.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.*; @@ -14,21 +13,36 @@ @Secured("ROLE_ADMIN") public class UsersRestController { - private UserRepository userRepository; + private UserService userService; @Autowired - public UsersRestController(UserRepository userRepository) { - this.userRepository = userRepository; + public UsersRestController(UserService userService) { + this.userService = userService; } @GetMapping public List users(){ - return this.userRepository.findAll(); + return this.userService.findAll(); } - @PutMapping("/{userId}") + @PostMapping + public User createUser(@RequestBody User user){ + return this.userService.create(user); + } + + @PutMapping("/{username}") public User saveUser(@RequestBody User user){ - return this.userRepository.save(user); + return this.userService.update(user); + } + + @PutMapping("/{username}/password") + public void changeUserPassword(@PathVariable String username, @RequestParam String password){ + this.userService.changeUserPassword(username, password); + } + + @DeleteMapping("/{username}") + public void deleteUser(@PathVariable String username){ + this.userService.deleteUser(username); } } diff --git a/src/test/features/io/gaia_app/e2e/basic_navigation.feature b/src/test/features/io/gaia_app/e2e/basic_navigation.feature index 311a3fc2e..7af6b3f3f 100644 --- a/src/test/features/io/gaia_app/e2e/basic_navigation.feature +++ b/src/test/features/io/gaia_app/e2e/basic_navigation.feature @@ -25,3 +25,7 @@ Feature: Basic Navigation Scenario: View job details When I go on the job '5e856dc7-6bed-465f-abf1-02980206ab2a' for stack 'de28a01f-257a-448d-8e1b-00e4e3a41db2' page Then Percy takes a snapshot named 'Job Details' + + Scenario: View users list + When I go on the users page + Then Percy takes a snapshot named 'Users' diff --git a/src/test/java/io/gaia_app/config/security/SecurityConfigIT.kt b/src/test/java/io/gaia_app/config/security/SecurityConfigIT.kt index 2b170afb0..b35870936 100644 --- a/src/test/java/io/gaia_app/config/security/SecurityConfigIT.kt +++ b/src/test/java/io/gaia_app/config/security/SecurityConfigIT.kt @@ -1,9 +1,11 @@ package io.gaia_app.config.security +import io.gaia_app.teams.User +import io.gaia_app.teams.repository.UserRepository import io.gaia_app.test.any +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -21,6 +23,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController +import java.util.* @SpringBootTest(classes = [SecurityConfig::class, SecurityConfigIT.FakeRestController::class]) @AutoConfigureMockMvc @@ -45,6 +48,16 @@ class SecurityConfigIT { @MockBean lateinit var authenticationEntryPoint: AuthenticationEntryPoint + @MockBean + lateinit var userRepository: UserRepository + + @BeforeEach + internal fun setUp() { + val adminUser = User("admin", null) + adminUser.password = "\$2a\$10\$hr8QjaJ0ync5OQoCtoown.XKplCdhAnyfkWaCf9fto9Cd4470hO/e" + `when`(userRepository.findById("admin")).thenReturn(Optional.of(adminUser)); + } + @Test fun `security should not authenticate assets access`() { mockMvc.perform(get("/assets/img/gaia.png")) @@ -84,7 +97,7 @@ class SecurityConfigIT { @Test fun `security should handle login success`() { - mockMvc.perform(formLogin("/auth/classic").user("admin").password("admin456")) + mockMvc.perform(formLogin("/auth/classic").user("admin").password("admin123")) .andExpect(authenticated().withUsername("admin")) verify(successHandler).onAuthenticationSuccess(any(), any(), any()) } diff --git a/src/test/java/io/gaia_app/dashboard/controller/DashboardRestControllerTest.kt b/src/test/java/io/gaia_app/dashboard/controller/DashboardRestControllerTest.kt index efe82f921..411388b2b 100644 --- a/src/test/java/io/gaia_app/dashboard/controller/DashboardRestControllerTest.kt +++ b/src/test/java/io/gaia_app/dashboard/controller/DashboardRestControllerTest.kt @@ -7,6 +7,7 @@ import io.gaia_app.teams.Team import io.gaia_app.teams.User import io.gaia_app.test.whenever import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -30,7 +31,13 @@ class DashboardRestControllerTest { @Nested inner class WhenAdminUserTest { - private val user = User("admin", null) + private lateinit var user: User + + @BeforeEach + internal fun setUp() { + user = User("admin", null) + user.isAdmin = true + } @Test fun `summary() should return modules count`() { diff --git a/src/test/java/io/gaia_app/e2e/stepDefs/UsersStepDefs.java b/src/test/java/io/gaia_app/e2e/stepDefs/UsersStepDefs.java new file mode 100644 index 000000000..f8e7b1dfb --- /dev/null +++ b/src/test/java/io/gaia_app/e2e/stepDefs/UsersStepDefs.java @@ -0,0 +1,16 @@ +package io.gaia_app.e2e.stepDefs; + +import io.cucumber.java.en.When; +import io.gaia_app.e2e.pages.JobPage; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.AjaxElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UsersStepDefs extends StepDefs { + + @When("I go on the users page") + public void iGoOnTheUsersPage() { + driver.get(baseUrl()+"/users"); + } +} diff --git a/src/test/java/io/gaia_app/modules/bo/TerraformModuleTest.java b/src/test/java/io/gaia_app/modules/bo/TerraformModuleTest.java index 9bc34a9e7..b8a2b8b0e 100644 --- a/src/test/java/io/gaia_app/modules/bo/TerraformModuleTest.java +++ b/src/test/java/io/gaia_app/modules/bo/TerraformModuleTest.java @@ -15,6 +15,7 @@ class TerraformModuleTest { void module_shouldBeAuthorized_forAdminUser(){ // given var admin = new User("admin", null); + admin.setAdmin(true); var module = new TerraformModule(); // when diff --git a/src/test/java/io/gaia_app/modules/controller/ModuleRestControllerTest.java b/src/test/java/io/gaia_app/modules/controller/ModuleRestControllerTest.java index 2d1bfc877..dfd71f6ca 100644 --- a/src/test/java/io/gaia_app/modules/controller/ModuleRestControllerTest.java +++ b/src/test/java/io/gaia_app/modules/controller/ModuleRestControllerTest.java @@ -47,6 +47,7 @@ class ModuleRestControllerTest { @BeforeEach void setUp() { admin = new User("admin", null); + admin.setAdmin(true); john = new User("John Dorian", null); diff --git a/src/test/java/io/gaia_app/stacks/controller/StackRestControllerTest.java b/src/test/java/io/gaia_app/stacks/controller/StackRestControllerTest.java index 1aa9ce718..31a8fe31a 100644 --- a/src/test/java/io/gaia_app/stacks/controller/StackRestControllerTest.java +++ b/src/test/java/io/gaia_app/stacks/controller/StackRestControllerTest.java @@ -16,6 +16,7 @@ import io.gaia_app.modules.bo.TerraformImage; import io.gaia_app.modules.bo.TerraformModule; import io.gaia_app.stacks.bo.Stack; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -63,6 +64,11 @@ class StackRestControllerTest { @Mock private CredentialsRepository credentialsRepository; + @BeforeEach + void setUp() { + adminUser.setAdmin(true); + } + @Test void listStack_shouldFindAllStacks_forAdminUser() { // when diff --git a/src/test/java/io/gaia_app/teams/controller/TeamsRestControllerIT.java b/src/test/java/io/gaia_app/teams/controller/TeamsRestControllerIT.java index 74acf56c3..a510fb115 100644 --- a/src/test/java/io/gaia_app/teams/controller/TeamsRestControllerIT.java +++ b/src/test/java/io/gaia_app/teams/controller/TeamsRestControllerIT.java @@ -1,19 +1,25 @@ package io.gaia_app.teams.controller; +import io.gaia_app.teams.Team; +import io.gaia_app.teams.repository.TeamRepository; import io.gaia_app.test.SharedMongoContainerTest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; 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.access.AccessDeniedException; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -28,6 +34,9 @@ class TeamsRestControllerIT extends SharedMongoContainerTest { @Autowired private TeamsRestController teamsRestController; + @Autowired + private TeamRepository teamRepository; + @Autowired private MockMvc mockMvc; @@ -39,22 +48,62 @@ void setUp() { } @Test - @WithMockUser("Mary J") - void teams_shouldBeAccessible_forStandardUsers() { - Assertions.assertDoesNotThrow(() -> teamsRestController.teams()); + void teams_shouldBeExposed_atSpecificUrl() throws Exception { + mockMvc.perform(get("/api/teams")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(3))) + .andExpect(jsonPath("$..id", contains("Ze Team", "Not Ze Team", "Sith"))); } @Test - void teams_shouldBeAccessible_forAdminUser() { - Assertions.assertDoesNotThrow(() -> teamsRestController.teams()); + void createOrganization_shouldCreateOrganization() throws Exception { + mockMvc.perform(post("/api/teams") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":\"Gungans\"}")) + .andExpect(status().isOk()); + + assertTrue(teamRepository.existsById("Gungans")); } @Test - void teams_shouldBeExposed_atSpecificUrl() throws Exception { - mockMvc.perform(get("/api/teams")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(3))) - .andExpect(jsonPath("$..id", contains("Ze Team", "Not Ze Team", "Sith"))); + void deleteOrganization_shouldDeleteOrganization() throws Exception { + teamRepository.save(new Team("First Order")); + + mockMvc.perform(delete("/api/teams/First Order") + .with(csrf())) + .andExpect(status().isOk()); + + assertFalse(teamRepository.existsById("First Order")); + } + + @Nested + class AccessControl { + + @Test + @WithMockUser("Jar Jar Binks") + void teams_shouldBeAccessible_forStandardUsers() { + Assertions.assertDoesNotThrow(() -> teamsRestController.teams()); + } + + @Test + @WithMockUser("Jar Jar Binks") + void createOrganization_shouldBeForbidden_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> teamsRestController.createOrganization(new Team("Gungans"))); + } + + @Test + @WithMockUser("Jar Jar Binks") + void deleteOrganization_shouldBeForbidden_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> teamsRestController.deleteOrganization("Gungans")); + } + + @Test + @WithMockUser(value = "admin", roles = "ADMIN") + void teams_shouldBeAccessible_forAdminUser() { + Assertions.assertDoesNotThrow(() -> teamsRestController.teams()); + } + } } diff --git a/src/test/java/io/gaia_app/teams/controller/UsersRestControllerIT.java b/src/test/java/io/gaia_app/teams/controller/UsersRestControllerIT.java index bce63e18c..a642f8e8f 100644 --- a/src/test/java/io/gaia_app/teams/controller/UsersRestControllerIT.java +++ b/src/test/java/io/gaia_app/teams/controller/UsersRestControllerIT.java @@ -6,22 +6,22 @@ import io.gaia_app.test.SharedMongoContainerTest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; 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.access.AccessDeniedException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -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.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,6 +40,9 @@ class UsersRestControllerIT extends SharedMongoContainerTest { @Autowired private UserRepository userRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired private MockMvc mockMvc; @@ -50,17 +53,6 @@ void setUp() { mongo.runScript("10_user.js"); } - @Test - @WithMockUser("Matthew Bellamy") - void users_shouldNotBeAccessible_forStandardUsers() { - assertThrows(AccessDeniedException.class, () -> usersRestController.users()); - } - - @Test - void users_shouldBeAccessible_forAdminUser() { - Assertions.assertDoesNotThrow(() -> usersRestController.users()); - } - @Test void users_shouldBeExposed_atSpecificUrl() throws Exception { mockMvc.perform(get("/api/users")) @@ -73,16 +65,16 @@ void users_shouldBeExposed_atSpecificUrl() throws Exception { @Test void saveUser_shouldBeExposed_atSpecificUrl() throws Exception { - mockMvc.perform(put("/api/users/test") + mockMvc.perform(put("/api/users/Luke Skywalker") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"Bob\"}")) + .content("{\"username\":\"Luke Skywalker\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.username", is("Bob"))) + .andExpect(jsonPath("$.username", is("Luke Skywalker"))) .andExpect(jsonPath("$.admin", is(false))) - .andExpect(jsonPath("$.team", isEmptyOrNullString())); + .andExpect(jsonPath("$.team", is(emptyOrNullString()))); - assertThat(userRepository.existsById("Bob")).isTrue(); + assertThat(userRepository.existsById("Luke Skywalker")).isTrue(); } @Test @@ -120,4 +112,89 @@ void users_shouldNotLeakTheirOAuth2Credentials() throws Exception { .andExpect(jsonPath("$[4].oauth2User.token").doesNotExist()); } + @Test + void users_shouldNotLeakTheirPassword() throws Exception { + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].username", is("admin"))) + .andExpect(jsonPath("$[0].password").doesNotExist()); + } + + @Test + void createUser_shouldSaveANewUser() throws Exception { + mockMvc.perform(post("/api/users") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"Obi Wan Kenobi\", \"password\": \"Use the Force, Luke.\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username", is("Obi Wan Kenobi"))) + .andExpect(jsonPath("$.admin", is(false))) + .andExpect(jsonPath("$.team", is(emptyOrNullString()))); + + assertThat(userRepository.existsById("Obi Wan Kenobi")).isTrue(); + } + + @Test + void deleteUser_shouldDeleteAUser() throws Exception { + var jarJarBinks = new User("Jar Jar Binks", null); + userRepository.save(jarJarBinks); + + mockMvc.perform(delete("/api/users/Jar Jar Binks") + .with(csrf())) + .andExpect(status().isOk()); + + assertThat(userRepository.existsById("Jar Jar Binks")).isFalse(); + } + + @Test + void changeUserPassword() throws Exception { + var newPassword = "I find your lack of faith disturbing"; + mockMvc.perform(put("/api/users/Darth Vader/password") + .with(csrf()) + .param("password", newPassword)) + .andExpect(status().isOk()); + + var darthVader = userRepository.findById("Darth Vader"); + assertThat(darthVader) + .isPresent() + .hasValueSatisfying(vader -> { + assertTrue(passwordEncoder.matches(newPassword, vader.getPassword())); + }); + } + + @Nested + class AccessControl { + + @Test + @WithMockUser("Jar Jar") + void users_shouldNotBeAccessible_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> usersRestController.users()); + } + + @Test + @WithMockUser(value = "admin", roles = "ADMIN") + void users_shouldBeAccessible_forAdminUser() { + Assertions.assertDoesNotThrow(() -> usersRestController.users()); + } + + @Test + @WithMockUser("Jar Jar") + void createUser_shouldNotBeAccessible_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> usersRestController.createUser(new User("Darth Vader", null))); + } + + @Test + @WithMockUser("Jar Jar") + void saveUser_shouldNotBeAccessible_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> usersRestController.saveUser(new User("Darth Vader", null))); + } + + @Test + @WithMockUser("Jar Jar") + void deleteUser_shouldNotBeAccessible_forStandardUsers() { + assertThrows(AccessDeniedException.class, () -> usersRestController.deleteUser("Darth Vader")); + } + + } + } diff --git a/src/test/java/io/gaia_app/teams/controller/UsersRestControllerTest.java b/src/test/java/io/gaia_app/teams/controller/UsersRestControllerTest.java index 02b678f3f..3adf53f02 100644 --- a/src/test/java/io/gaia_app/teams/controller/UsersRestControllerTest.java +++ b/src/test/java/io/gaia_app/teams/controller/UsersRestControllerTest.java @@ -1,7 +1,7 @@ package io.gaia_app.teams.controller; import io.gaia_app.teams.User; -import io.gaia_app.teams.repository.UserRepository; +import io.gaia_app.teams.UserService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -14,23 +14,46 @@ class UsersRestControllerTest { @Mock - private UserRepository userRepository; + private UserService userService; @InjectMocks private UsersRestController usersRestController; @Test - void users_shouldReturnAllTeams() { + void users_shouldReturnAllUsers() { usersRestController.users(); - verify(userRepository).findAll(); + verify(userService).findAll(); } + @Test + void createUser_shouldSaveTheUser() { + var john = new User("john", null); + usersRestController.createUser(john); + + verify(userService).create(john); + } + + @Test void saveUser_shouldSaveTheUser() { var john = new User("john", null); usersRestController.saveUser(john); - verify(userRepository).save(john); + verify(userService).update(john); + } + + @Test + void deleteUser_shouldDeleteTheUser() { + usersRestController.deleteUser("john"); + + verify(userService).deleteUser("john"); + } + + @Test + void changeUserPassword_shouldChangeThePassword() { + usersRestController.changeUserPassword("john", "password"); + + verify(userService).changeUserPassword("john", "password"); } } diff --git a/src/test/resources/db/10_user.js b/src/test/resources/db/10_user.js index 5703599d2..d6dfb8434 100644 --- a/src/test/resources/db/10_user.js +++ b/src/test/resources/db/10_user.js @@ -3,7 +3,10 @@ gaia.user.drop(); gaia.user.insert([ { "_id": "admin", - "team": {"$ref": "team", "$id": "Ze Team"} + "team": {"$ref": "team", "$id": "Ze Team"}, + "isAdmin": true, + // admin123 + "password": "$2a$10$hr8QjaJ0ync5OQoCtoown.XKplCdhAnyfkWaCf9fto9Cd4470hO/e" }, { "_id": "Mary J",