Skip to content
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
package com.openelements.crm.user;

import com.openelements.crm.security.RequiresItAdmin;
import com.openelements.spring.base.security.user.UserDto;
import com.openelements.spring.base.security.user.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@Tag(name = "Users")
@SecurityRequirement(name = "oidc")
public class UserController {

private final UserService userService;
Expand All @@ -25,4 +33,13 @@ public UserDto getMe() {
return userService.getCurrentUser();
}

@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@RequiresItAdmin
@Operation(summary = "List users", description = "Returns a paginated list of all registered users. Requires the IT-ADMIN role.")
public Page<UserDto> listUsers(
@Parameter(hidden = true)
@PageableDefault(size = 20) final Pageable pageable) {
return userService.findAll(pageable);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import com.openelements.crm.contact.ContactController;
import com.openelements.crm.tag.TagController;
import com.openelements.crm.task.TaskController;
import com.openelements.crm.user.UserController;
import com.openelements.crm.webhook.WebhookController;
import java.lang.reflect.Method;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;

/**
* Verifies that every delete endpoint carries {@code @RequiresAdmin}
Expand Down Expand Up @@ -83,13 +85,26 @@ void brevoSyncControllerRequiresItAdmin() {
assertClassHasRequiresItAdmin(BrevoSyncController.class);
}

@Test
void userControllerListUsersRequiresItAdmin() throws NoSuchMethodException {
assertHasRequiresItAdmin(UserController.class.getDeclaredMethod(
"listUsers", Pageable.class));
}

private static void assertHasRequiresAdmin(Method method) {
final RequiresAdmin annotation = method.getAnnotation(RequiresAdmin.class);
assertNotNull(annotation,
"Missing @RequiresAdmin on " + method.getDeclaringClass().getSimpleName()
+ "." + method.getName());
}

private static void assertHasRequiresItAdmin(Method method) {
final RequiresItAdmin annotation = method.getAnnotation(RequiresItAdmin.class);
assertNotNull(annotation,
"Missing @RequiresItAdmin on " + method.getDeclaringClass().getSimpleName()
+ "." + method.getName());
}

private static void assertClassHasRequiresItAdmin(Class<?> controller) {
final RequiresItAdmin annotation = controller.getAnnotation(RequiresItAdmin.class);
assertNotNull(annotation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,47 @@ void brevoSettingsAllowedForItAdmin() {
get("/api/brevo/settings"), List.of("IT-ADMIN"))));
}

// -- GET /api/users requires IT-ADMIN (spec 089) --

@Test
void usersListUnauthenticatedReturns401() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}

@Test
void usersListForbiddenForUserNone() throws Exception {
mockMvc.perform(withRoles(get("/api/users"), List.of()))
.andExpect(status().isForbidden());
}

@Test
void usersListForbiddenForAdminOnly() throws Exception {
mockMvc.perform(withRoles(get("/api/users"), List.of("ADMIN")))
.andExpect(status().isForbidden());
}

@Test
void usersListAllowedForItAdmin() throws Exception {
mockMvc.perform(withRoles(get("/api/users"), List.of("IT-ADMIN")))
.andExpect(status().isOk());
}

@Test
void usersListAllowedForUserBoth() throws Exception {
mockMvc.perform(withRoles(get("/api/users"),
List.of("ADMIN", "IT-ADMIN")))
.andExpect(status().isOk());
}

@Test
void usersMeRemainsAccessibleForUserNone() throws Exception {
// /api/users/me must remain accessible to any authenticated user;
// the IT-ADMIN restriction only applies to the list endpoint.
assertRoleCheckPassed(() -> mockMvc.perform(withRoles(
get("/api/users/me"), List.of())));
}

/**
* Performs the supplied MockMvc call. If it succeeds, asserts the status is
* not 403. If it throws during servlet processing (business logic error),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.openelements.crm.user;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.Collection;
import java.util.List;
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.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

/**
* Behaviour tests for the paginated GET /api/users endpoint added in spec 089.
*
* <p>Role-based 401/403/200 coverage lives in
* {@link com.openelements.crm.security.SecurityRoleIntegrationTest}; this class
* focuses on the pagination contract (default page size, explicit page/size,
* response shape).
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

private static MockHttpServletRequestBuilder asItAdmin(MockHttpServletRequestBuilder builder) {
final List<String> roles = List.of("IT-ADMIN");
final Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("test-user")
.claim("preferred_username", "test-user")
.claim("email", "test@example.com")
.claim("roles", roles)
.build();
final Collection<GrantedAuthority> authorities = new ArrayList<>();
for (final String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
return builder.with(jwt().jwt(jwt).authorities(authorities));
}

@Test
void listUsersReturnsPagedResponseShape() throws Exception {
mockMvc.perform(asItAdmin(get("/api/users")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.page.size").exists())
.andExpect(jsonPath("$.page.number").exists())
.andExpect(jsonPath("$.page.totalElements").exists())
.andExpect(jsonPath("$.page.totalPages").exists());
}

@Test
void listUsersUsesDefaultPageSize20WithoutParams() throws Exception {
mockMvc.perform(asItAdmin(get("/api/users")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.size").value(20))
.andExpect(jsonPath("$.page.number").value(0));
}

@Test
void listUsersHonoursExplicitPageAndSize() throws Exception {
mockMvc.perform(asItAdmin(get("/api/users").param("page", "0").param("size", "10")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.size").value(10))
.andExpect(jsonPath("$.page.number").value(0));
}
}
Loading
Loading