Files
user-management/src/test/java/com/cleverthis/authservice/AuthControllerTest.java
T
hurui200320 eaa14e7101
Unit test coverage / gradle-test (pull_request) Failing after 2m36s
Unit test coverage / gradle-test (push) Failing after 1m40s
CI for publishing docker image / build-and-publish (push) Successful in 2m20s
feat(General): implement consul config permission mapping resolving with fallback to application yam
Pull config from consul to resolve permission mapping dynamically

if consul is not available or the

value is corrupted, use settings from local value in application.yaml

ISSUES CLOSED: clevermicro/user-management#23
2025-06-13 12:17:06 +08:00

465 lines
22 KiB
Java

package com.cleverthis.authservice;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItems;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.controller.AuthController;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
/**
* Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the
* controller logic and request/response handling.
*/
@WebFluxTest(AuthController.class)
@Import({GlobalExceptionHandler.class,
PermissionMappingConfig.class,
FallbackPermissionMapLookupService.class})
class AuthControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
@MockitoBean
private PermissionMappingConfig permissionMappingConfig;
@Captor
private ArgumentCaptor<List<String>> candidateRolesCaptor;
// required by the fallback permission mapping lookup service
// provide a mocked one
@MockitoBean
private ConsulClient consulClient;
private TokenResponse mockTokenResponse;
private VerificationResponse mockUserVerificationResponse;
// --- Constants for Header Names (mirroring controller) ---
private static final String HEADER_USER_ID = "X-User-Id";
private static final String HEADER_USER_NAME = "X-User-Name";
private static final String HEADER_USER_EMAIL = "X-User-Email";
private static final String HEADER_USER_GROUPS = "X-User-Groups";
// --- Traefik Forwarded Headers ---
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri";
@BeforeEach
void setUp() {
this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer",
"id-789", 0, "state", "openid");
this.mockUserVerificationResponse =
new VerificationResponse(true, "test-client", "testuser", "openid", "user-sub-123",
System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000,
System.currentTimeMillis() / 1000, List.of("aud1"), "iss", "Bearer",
"test@example.com", true, "testuser", List.of("groupA"));
// Setup mock PermissionMappingConfig - Default to having some rules
PermissionMappingConfig.Rule rule1 = new PermissionMappingConfig.Rule();
rule1.setPathPrefix("/serviceA/");
rule1.setKeycloakClientId("serviceA-client");
PermissionMappingConfig.Rule rule2 = new PermissionMappingConfig.Rule();
rule2.setPathPrefix("/serviceB/items/");
rule2.setKeycloakClientId("serviceB-client");
List<PermissionMappingConfig.Rule> rules = new ArrayList<>();
rules.add(rule1);
rules.add(rule2);
// Mock getRules() to return the list
when(this.permissionMappingConfig.getRules()).thenReturn(rules);
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
// specific tests
when(this.permissionMappingConfig.getDefaultKeycloakClientId()).thenReturn(null);
}
// --- /login Tests ---
@Test
void login_Success_ReturnsTokenResponse() {
LoginRequest loginRequest = new LoginRequest("testuser", "password");
when(this.identityManagerService.login("testuser", "password"))
.thenReturn(Mono.just(this.mockTokenResponse));
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200
.expectBody(TokenResponse.class)
.value(TokenResponse::getAccessToken, equalTo("access-123"))
.value(TokenResponse::getRefreshToken, equalTo("refresh-456"));
}
@Test
void login_ServiceThrowsAuthException_ReturnsUnauthorized() {
LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass");
// Mock the service to throw the specific exception handled by GlobalExceptionHandler
when(this.identityManagerService.login("wronguser", "wrongpass"))
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isUnauthorized();
}
@Test
void login_InvalidRequest_ReturnsBadRequest() {
LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request
// No need to mock service for validation errors
// The framework and GlobalExceptionHandler (via parent) should handle this
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
// --- /verify Tests ---
@Test
void verify_Success_ReturnsVerificationResponse() {
String validToken = "valid-token-123";
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus()
.isOk() // Expect 200
.expectBody(VerificationResponse.class)
.value(VerificationResponse::isActive, equalTo(true))
.value(VerificationResponse::getUsername, equalTo("testuser"));
}
@Test
void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() {
String invalidToken = "invalid-token-456";
// Mock the service to throw the specific exception
when(this.identityManagerService.verifyToken(invalidToken))
.thenReturn(Mono.error(new TokenVerificationException("Token expired")));
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange()
.expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler)
}
@Test
void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity
// No service mocking needed, framework handles missing required header
this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest();
}
@Test
void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
// Controller logic handles this case specifically
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme
.exchange().expectStatus().isUnauthorized(); // Expect 401 (handled by controller
}
// --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data"; // Matches rule1
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionGranted_SpecificRoleMatch() {
String userToken = "user-token-granted";
String originalMethod = "GET";
// Will be normalized to /api/items/BY_ID
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// Expect checkUserHasAnyClientRole to be called with a list of candidates
// For GET /api/items/BY_ID, candidates would be [GET_API_ITEMS_BY_ID, ALL_API_ITEMS_BY_ID,
// GET_API_ITEMS_STAR, ..., ALL_STAR]
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
// Verify the captured candidate roles
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
assertThat(capturedRoles,
hasItems("GET_API_ITEMS_BY_ID", "ALL_API_ITEMS_BY_ID", "GET_API_ITEMS_STAR",
"ALL_API_ITEMS_STAR", "GET_API_STAR", "ALL_API_STAR", "GET_STAR",
"ALL_STAR"));
// Check order if important, or just presence
}
@Test
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
String validToken = "valid-token-fw-nogroups";
VerificationResponse responseWithoutGroups = new VerificationResponse(true, "test-client",
"testuser", "openid", "user-sub-123", System.currentTimeMillis() / 1000 + 300,
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
List.of("aud1"), "iss", "Bearer", "test@example.com", true, "testuser", null);
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
.header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/nogroups").exchange().expectStatus()
.isOk().expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionGranted_WildcardRoleMatch_All_Star() {
String userToken = "user-token-admin";
String originalMethod = "DELETE";
String originalUri = "/serviceA/admin/config"; // -> /admin/config
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// User has ALL_STAR, so this should pass
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), anyStringList() // Match any list of strings
)).thenAnswer(invocation -> {
List<String> candidates = invocation.getArgument(2);
// Simulate Keycloak returning true if ALL_STAR is among candidates and user has it
// For this test, we assume the service method checkUserHasAnyClientRole correctly finds
// ALL_STAR
// if the user has it and it's in the candidate list.
// Here, we just return true because the test name implies it should be granted.
// A more robust mock would check if "ALL_STAR" is in `candidates` if the user had
// ALL_STAR.
return Mono.just(true);
});
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionDenied_NoMatchingRole() {
String userToken = "user-token-denied";
String originalMethod = "POST";
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), anyStringList() // Match any list of strings
)).thenReturn(Mono.just(false)); // Service reports no matching role found
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isForbidden()
.expectBody().isEmpty();
}
@Test
void forwardAuth_RootPath_PermissionGranted() {
String userToken = "user-token-root-access";
String originalMethod = "GET";
String originalUri = "/serviceA/"; // Root path for serviceA
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk();
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
assertThat(capturedRoles, hasItems("GET_ROOT", "ALL_ROOT", "GET_STAR", "ALL_STAR"));
}
@Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(invalidUserToken))
.thenReturn(Mono.error(new TokenVerificationException("Token is expired")));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidUserToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_ServiceThrowsGenericExceptionDuringVerification_ReturnsUnauthorized() {
String token = "token-causing-error";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(token)).thenReturn(
Mono.error(new RuntimeException("Internal service error during verification")));
this.webTestClient.post().uri("/auth").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_NoMatchingPathRule_ReturnsForbidden() {
String userToken = "user-token-no-rule";
String originalMethod = "GET";
String originalUri = "/unknownService/api/data"; // No rule matches this prefix
// Mock getRules to return an empty list for this specific test case, or ensure default
// setup handles it
// For this test, let's ensure the default setup from setUp() (which has rules for
// /serviceA/)
// does not match "/unknownService/"
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// No call to checkUserClientRolePermission should happen if no rule matches
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isForbidden(); // Controller logic for no rule match
}
@Test
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
this.webTestClient.post().uri("/auth").header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
// Missing Authorization header
.exchange().expectStatus().isBadRequest();
}
@Test
void forwardAuth_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
// This tests the controller's specific check for "Bearer " prefix
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials")
.header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data").exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_MissingXforwardedMethodHeader_ReturnsBadRequest() {
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
// Missing X-Forwarded-Method
.exchange().expectStatus().isBadRequest();
}
@Test
void forwardAuth_MissingXforwardedUriHeader_ReturnsBadRequest() {
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
.header(HEADER_X_FORWARDED_METHOD, "GET")
// Missing X-Forwarded-Uri
.exchange().expectStatus().isBadRequest();
}
// --- /logout Tests ---
@Test
void logout_Success_ReturnsNoContent() {
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke");
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204
}
@Test
void logout_ServiceThrowsException_ReturnsInternalServerError() {
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error");
// Mock service to throw the specific exception
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
}
@Test
void logout_InvalidRequest_ReturnsBadRequest() {
LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token)
// No service mocking needed
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
// Helper to match any List<String> in Mockito
private static List<String> anyStringList() {
return Mockito.anyList();
}
}