Explorar el Código

add unit tests

Daniel Bohry hace 3 semanas
padre
commit
780befd1c7

+ 3 - 0
build.gradle

@@ -36,6 +36,9 @@ dependencies {
     compileOnly 'org.projectlombok:lombok'
     annotationProcessor 'org.projectlombok:lombok'
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
+    testImplementation 'org.springframework.security:spring-security-test'
+    testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure'
+    testImplementation 'org.springframework.boot:spring-boot-starter-web'
     testImplementation 'org.mockito:mockito-core:5.14.2'
 }
 

+ 212 - 0
src/test/java/com/danielbohry/authservice/JwtTestUtils.java

@@ -0,0 +1,212 @@
+package com.danielbohry.authservice;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Set;
+
+/**
+ * Utility class for JWT-related testing operations.
+ * Provides methods for creating test tokens and validating JWT behavior.
+ */
+public class JwtTestUtils {
+
+    private static final String TEST_SECRET = "test-secret-key-for-jwt-testing-that-is-long-enough-to-be-256-bits-for-hmac-sha-algorithms";
+
+    /**
+     * Creates a valid JWT token for testing
+     */
+    public static String createValidToken(String username) {
+        return createTokenWithExpiration(username, 1, ChronoUnit.HOURS);
+    }
+
+    /**
+     * Creates a JWT token with specified expiration
+     */
+    public static String createTokenWithExpiration(String username, long amount, ChronoUnit unit) {
+        Date issuedAt = new Date();
+        Date expiration = Date.from(Instant.now().plus(amount, unit));
+
+        return Jwts.builder()
+                .subject(username)
+                .issuedAt(issuedAt)
+                .expiration(expiration)
+                .claim("authorities", Set.of("ROLE_USER"))
+                .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Creates an expired JWT token for testing
+     */
+    public static String createExpiredToken(String username) {
+        return createTokenWithExpiration(username, -1, ChronoUnit.HOURS);
+    }
+
+    /**
+     * Creates a JWT token with custom authorities
+     */
+    public static String createTokenWithAuthorities(String username, Set<String> authorities) {
+        Date issuedAt = new Date();
+        Date expiration = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
+
+        return Jwts.builder()
+                .subject(username)
+                .issuedAt(issuedAt)
+                .expiration(expiration)
+                .claim("authorities", authorities)
+                .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Creates a JWT token that expires soon (useful for testing expiration edge cases)
+     */
+    public static String createSoonToExpireToken(String username, long secondsUntilExpiration) {
+        return createTokenWithExpiration(username, secondsUntilExpiration, ChronoUnit.SECONDS);
+    }
+
+    /**
+     * Creates a JWT token with admin role
+     */
+    public static String createAdminToken(String username) {
+        return createTokenWithAuthorities(username, Set.of("ROLE_ADMIN", "ROLE_USER"));
+    }
+
+    /**
+     * Creates a JWT token with service role
+     */
+    public static String createServiceToken(String username) {
+        return createTokenWithAuthorities(username, Set.of("ROLE_SERVICE"));
+    }
+
+    /**
+     * Creates a JWT token with VPN role
+     */
+    public static String createVpnToken(String username) {
+        return createTokenWithAuthorities(username, Set.of("ROLE_VPN"));
+    }
+
+    /**
+     * Creates a malformed JWT token for testing error handling
+     */
+    public static String createMalformedToken() {
+        return "invalid.jwt.token";
+    }
+
+    /**
+     * Creates a JWT token with invalid signature for testing
+     */
+    public static String createInvalidSignatureToken(String username) {
+        String wrongSecret = "wrong-secret-key-for-testing-invalid-signatures-that-is-long-enough";
+
+        Date issuedAt = new Date();
+        Date expiration = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
+
+        return Jwts.builder()
+                .subject(username)
+                .issuedAt(issuedAt)
+                .expiration(expiration)
+                .claim("authorities", Set.of("ROLE_USER"))
+                .signWith(Keys.hmacShaKeyFor(wrongSecret.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Creates a JWT token without required claims for testing
+     */
+    public static String createTokenWithoutClaims() {
+        Date expiration = Date.from(Instant.now().plus(1, ChronoUnit.HOURS));
+
+        return Jwts.builder()
+                .expiration(expiration)
+                .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Creates a JWT token with custom expiration time for role-based testing
+     * Used to test the role-based expiration logic
+     */
+    public static String createTokenForRoleExpiration(String username, String role, long expectedHours) {
+        Date issuedAt = new Date();
+        Date expiration = Date.from(Instant.now().plus(expectedHours, ChronoUnit.HOURS));
+
+        return Jwts.builder()
+                .subject(username)
+                .issuedAt(issuedAt)
+                .expiration(expiration)
+                .claim("authorities", Set.of("ROLE_" + role.toUpperCase()))
+                .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Gets the test secret used for token creation (useful for service testing)
+     */
+    public static String getTestSecret() {
+        return TEST_SECRET;
+    }
+
+    /**
+     * Creates a JWT token with custom issued at date
+     */
+    public static String createTokenWithIssuedAt(String username, Instant issuedAt) {
+        Date issued = Date.from(issuedAt);
+        Date expiration = Date.from(issuedAt.plus(1, ChronoUnit.HOURS));
+
+        return Jwts.builder()
+                .subject(username)
+                .issuedAt(issued)
+                .expiration(expiration)
+                .claim("authorities", Set.of("ROLE_USER"))
+                .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .compact();
+    }
+
+    /**
+     * Creates a Bearer token string (with "Bearer " prefix)
+     */
+    public static String createBearerToken(String username) {
+        return "Bearer " + createValidToken(username);
+    }
+
+    /**
+     * Creates an Authorization header value
+     */
+    public static String createAuthorizationHeader(String username) {
+        return createBearerToken(username);
+    }
+
+    /**
+     * Extracts the username from a JWT token (for test validation)
+     */
+    public static String extractUsernameFromToken(String token) {
+        return Jwts.parser()
+                .verifyWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                .build()
+                .parseSignedClaims(token)
+                .getPayload()
+                .getSubject();
+    }
+
+    /**
+     * Checks if a token is expired (for test validation)
+     */
+    public static boolean isTokenExpired(String token) {
+        try {
+            Date expiration = Jwts.parser()
+                    .verifyWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes()))
+                    .build()
+                    .parseSignedClaims(token)
+                    .getPayload()
+                    .getExpiration();
+            return expiration.before(new Date());
+        } catch (Exception e) {
+            return true; // If we can't parse it, consider it expired
+        }
+    }
+}

+ 245 - 0
src/test/java/com/danielbohry/authservice/SecurityTestUtils.java

@@ -0,0 +1,245 @@
+package com.danielbohry.authservice;
+
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.test.context.support.WithSecurityContextFactory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Utility class for security-related testing operations.
+ * Provides methods for setting up mock security contexts and authentication.
+ */
+public class SecurityTestUtils {
+
+    /**
+     * Sets up a mock security context with the given ApplicationUser
+     */
+    public static void mockSecurityContext(ApplicationUser user) {
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+
+        when(authentication.getPrincipal()).thenReturn(user);
+        when(authentication.getName()).thenReturn(user.getUsername());
+        when(authentication.getAuthorities()).thenReturn((Collection) user.getAuthorities());
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+
+        SecurityContextHolder.setContext(securityContext);
+    }
+
+    /**
+     * Sets up a mock security context with a user having the specified role
+     */
+    public static void mockSecurityContextWithRole(String username, Role role) {
+        ApplicationUser user = ApplicationUser.builder()
+                .id("test-user-id")
+                .username(username)
+                .password("password")
+                .roles(List.of(role))
+                .active(true)
+                .build();
+
+        mockSecurityContext(user);
+    }
+
+    /**
+     * Sets up a mock security context with a user having multiple roles
+     */
+    public static void mockSecurityContextWithRoles(String username, List<Role> roles) {
+        ApplicationUser user = ApplicationUser.builder()
+                .id("test-user-id")
+                .username(username)
+                .password("password")
+                .roles(roles)
+                .active(true)
+                .build();
+
+        mockSecurityContext(user);
+    }
+
+    /**
+     * Creates a UsernamePasswordAuthenticationToken for testing
+     */
+    public static UsernamePasswordAuthenticationToken createAuthenticationToken(ApplicationUser user) {
+        return new UsernamePasswordAuthenticationToken(
+                user,
+                user.getPassword(),
+                user.getAuthorities()
+        );
+    }
+
+    /**
+     * Creates a UsernamePasswordAuthenticationToken with username and password
+     */
+    public static UsernamePasswordAuthenticationToken createAuthenticationToken(String username, String password) {
+        return new UsernamePasswordAuthenticationToken(username, password);
+    }
+
+    /**
+     * Creates a UsernamePasswordAuthenticationToken with authorities
+     */
+    public static UsernamePasswordAuthenticationToken createAuthenticationTokenWithRoles(
+            String username, String password, List<Role> roles) {
+        List<SimpleGrantedAuthority> authorities = roles.stream()
+                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
+                .collect(Collectors.toList());
+
+        return new UsernamePasswordAuthenticationToken(username, password, authorities);
+    }
+
+    /**
+     * Clears the security context
+     */
+    public static void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    /**
+     * Sets up an authenticated security context for testing
+     */
+    public static void authenticateUser(String username, List<Role> roles) {
+        ApplicationUser user = ApplicationUser.builder()
+                .id("test-user-id")
+                .username(username)
+                .password("encoded-password")
+                .roles(roles)
+                .active(true)
+                .build();
+
+        UsernamePasswordAuthenticationToken authentication =
+                new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
+
+        SecurityContext context = SecurityContextHolder.createEmptyContext();
+        context.setAuthentication(authentication);
+        SecurityContextHolder.setContext(context);
+    }
+
+    /**
+     * Sets up an authenticated security context for a standard user
+     */
+    public static void authenticateAsUser(String username) {
+        authenticateUser(username, List.of(Role.USER));
+    }
+
+    /**
+     * Sets up an authenticated security context for an admin user
+     */
+    public static void authenticateAsAdmin(String username) {
+        authenticateUser(username, List.of(Role.ADMIN, Role.USER));
+    }
+
+    /**
+     * Sets up an authenticated security context for a service user
+     */
+    public static void authenticateAsService(String username) {
+        authenticateUser(username, List.of(Role.SERVICE));
+    }
+
+    /**
+     * Sets up an authenticated security context for a VPN user
+     */
+    public static void authenticateAsVpn(String username) {
+        authenticateUser(username, List.of(Role.VPN));
+    }
+
+    /**
+     * Checks if the current security context has a specific role
+     */
+    public static boolean hasRole(Role role) {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null) {
+            return false;
+        }
+
+        return authentication.getAuthorities().stream()
+                .anyMatch(authority -> authority.getAuthority().equals("ROLE_" + role.name()));
+    }
+
+    /**
+     * Gets the current authenticated user from the security context
+     */
+    public static ApplicationUser getCurrentUser() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication != null && authentication.getPrincipal() instanceof ApplicationUser) {
+            return (ApplicationUser) authentication.getPrincipal();
+        }
+        return null;
+    }
+
+    /**
+     * Creates a mock Authentication object
+     */
+    public static Authentication createMockAuthentication(ApplicationUser user) {
+        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
+    }
+
+    /**
+     * Sets up a security context that will return null authentication (unauthenticated)
+     */
+    public static void mockUnauthenticatedContext() {
+        SecurityContext securityContext = mock(SecurityContext.class);
+        when(securityContext.getAuthentication()).thenReturn(null);
+        SecurityContextHolder.setContext(securityContext);
+    }
+
+    /**
+     * Sets up a security context with a non-ApplicationUser principal
+     */
+    public static void mockSecurityContextWithStringPrincipal(String principal) {
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+
+        when(authentication.getPrincipal()).thenReturn(principal);
+        when(authentication.getName()).thenReturn(principal);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+
+        SecurityContextHolder.setContext(securityContext);
+    }
+
+    /**
+     * Custom security context factory for testing with @WithUserDetails
+     */
+    public static class WithMockApplicationUserSecurityContextFactory
+            implements WithSecurityContextFactory<WithMockApplicationUser> {
+
+        @Override
+        public SecurityContext createSecurityContext(WithMockApplicationUser annotation) {
+            ApplicationUser user = ApplicationUser.builder()
+                    .id(annotation.id())
+                    .username(annotation.username())
+                    .password("encoded-password")
+                    .roles(List.of(annotation.roles()))
+                    .active(annotation.active())
+                    .build();
+
+            Authentication authentication = createAuthenticationToken(user);
+            SecurityContext context = SecurityContextHolder.createEmptyContext();
+            context.setAuthentication(authentication);
+            return context;
+        }
+    }
+
+    /**
+     * Custom annotation for testing with mock ApplicationUser
+     */
+    @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
+    @java.lang.annotation.Target({java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.TYPE})
+    @org.springframework.security.test.context.support.WithSecurityContext(factory = WithMockApplicationUserSecurityContextFactory.class)
+    public @interface WithMockApplicationUser {
+        String id() default "test-user-id";
+        String username() default "testuser";
+        Role[] roles() default {Role.USER};
+        boolean active() default true;
+    }
+}

+ 237 - 0
src/test/java/com/danielbohry/authservice/TestDataFactory.java

@@ -0,0 +1,237 @@
+package com.danielbohry.authservice;
+
+import com.danielbohry.authservice.api.dto.AuthenticationRequest;
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.api.dto.PasswordChangeRequest;
+import com.danielbohry.authservice.api.dto.UserResponse;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.auth.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Utility class for creating test data objects.
+ * Provides builder methods and common test objects to reduce test code duplication.
+ */
+public class TestDataFactory {
+
+    // Default test values
+    public static final String DEFAULT_USER_ID = "test-user-id-123";
+    public static final String DEFAULT_USERNAME = "testuser";
+    public static final String DEFAULT_PASSWORD = "password123";
+    public static final String DEFAULT_ENCODED_PASSWORD = "encodedPassword123";
+    public static final String DEFAULT_EMAIL = "test@example.com";
+    public static final String DEFAULT_JWT_TOKEN = "jwt-token-123";
+    public static final String DEFAULT_ADMIN_USERNAME = "admin";
+    public static final String DEFAULT_SERVICE_USERNAME = "service";
+    public static final String DEFAULT_VPN_USERNAME = "vpn";
+
+    /**
+     * Creates a basic ApplicationUser with USER role
+     */
+    public static ApplicationUser createUser() {
+        return ApplicationUser.builder()
+                .id(DEFAULT_USER_ID)
+                .username(DEFAULT_USERNAME)
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .email(DEFAULT_EMAIL)
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+    }
+
+    /**
+     * Creates an ApplicationUser with ADMIN role
+     */
+    public static ApplicationUser createAdminUser() {
+        return ApplicationUser.builder()
+                .id("admin-id-123")
+                .username(DEFAULT_ADMIN_USERNAME)
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .email("admin@example.com")
+                .roles(List.of(Role.ADMIN))
+                .active(true)
+                .build();
+    }
+
+    /**
+     * Creates an ApplicationUser with multiple roles
+     */
+    public static ApplicationUser createMultiRoleUser() {
+        return ApplicationUser.builder()
+                .id("multi-role-id-123")
+                .username("multirole")
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .email("multirole@example.com")
+                .roles(List.of(Role.ADMIN, Role.USER, Role.SERVICE))
+                .active(true)
+                .build();
+    }
+
+    /**
+     * Creates an ApplicationUser with specified role
+     */
+    public static ApplicationUser createUserWithRole(Role role) {
+        return ApplicationUser.builder()
+                .id(UUID.randomUUID().toString())
+                .username("user-" + role.name().toLowerCase())
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .email(role.name().toLowerCase() + "@example.com")
+                .roles(List.of(role))
+                .active(true)
+                .build();
+    }
+
+    /**
+     * Creates an inactive ApplicationUser
+     */
+    public static ApplicationUser createInactiveUser() {
+        return ApplicationUser.builder()
+                .id("inactive-user-id")
+                .username("inactiveuser")
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .email("inactive@example.com")
+                .roles(List.of(Role.USER))
+                .active(false)
+                .build();
+    }
+
+    /**
+     * Creates a UserDetails object for Spring Security
+     */
+    public static UserDetails createUserDetails() {
+        return User.builder()
+                .username(DEFAULT_USERNAME)
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+    }
+
+    /**
+     * Creates a UserDetails object with specified role
+     */
+    public static UserDetails createUserDetailsWithRole(Role role) {
+        return User.builder()
+                .username("user-" + role.name().toLowerCase())
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + role.name())))
+                .build();
+    }
+
+    /**
+     * Creates an AuthenticationRequest
+     */
+    public static AuthenticationRequest createAuthRequest() {
+        return new AuthenticationRequest(DEFAULT_USERNAME, DEFAULT_PASSWORD);
+    }
+
+    /**
+     * Creates an AuthenticationRequest with custom credentials
+     */
+    public static AuthenticationRequest createAuthRequest(String username, String password) {
+        return new AuthenticationRequest(username, password);
+    }
+
+    /**
+     * Creates an AuthenticationResponse
+     */
+    public static AuthenticationResponse createAuthResponse() {
+        return AuthenticationResponse.builder()
+                .id(DEFAULT_USER_ID)
+                .username(DEFAULT_USERNAME)
+                .token(DEFAULT_JWT_TOKEN)
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(List.of("ROLE_USER"))
+                .build();
+    }
+
+    /**
+     * Creates an AuthenticationResponse with specified parameters
+     */
+    public static AuthenticationResponse createAuthResponse(String userId, String username, List<String> roles) {
+        return AuthenticationResponse.builder()
+                .id(userId)
+                .username(username)
+                .token(DEFAULT_JWT_TOKEN)
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(roles)
+                .build();
+    }
+
+    /**
+     * Creates a JWT Authentication record
+     */
+    public static Authentication createAuthentication() {
+        return new Authentication(
+                DEFAULT_JWT_TOKEN,
+                Instant.now().plusSeconds(3600),
+                DEFAULT_USERNAME,
+                List.of("ROLE_USER")
+        );
+    }
+
+    /**
+     * Creates a JWT Authentication record with specified expiration
+     */
+    public static Authentication createAuthentication(long expirationSeconds) {
+        return new Authentication(
+                DEFAULT_JWT_TOKEN,
+                Instant.now().plusSeconds(expirationSeconds),
+                DEFAULT_USERNAME,
+                List.of("ROLE_USER")
+        );
+    }
+
+    /**
+     * Creates a PasswordChangeRequest
+     */
+    public static PasswordChangeRequest createPasswordChangeRequest() {
+        return new PasswordChangeRequest("oldPassword", "newPassword");
+    }
+
+    /**
+     * Creates a PasswordChangeRequest with custom passwords
+     */
+    public static PasswordChangeRequest createPasswordChangeRequest(String currentPassword, String newPassword) {
+        return new PasswordChangeRequest(currentPassword, newPassword);
+    }
+
+    /**
+     * Creates a UserResponse
+     */
+    public static UserResponse createUserResponse() {
+        return new UserResponse(DEFAULT_USER_ID, DEFAULT_USERNAME, List.of("USER"));
+    }
+
+    /**
+     * Creates a UserResponse with specified parameters
+     */
+    public static UserResponse createUserResponse(String id, String username, List<String> roles) {
+        return new UserResponse(id, username, roles);
+    }
+
+    /**
+     * Creates an ApplicationUser builder for custom configuration
+     */
+    public static ApplicationUser.ApplicationUserBuilder userBuilder() {
+        return ApplicationUser.builder()
+                .id(UUID.randomUUID().toString())
+                .password(DEFAULT_ENCODED_PASSWORD)
+                .active(true);
+    }
+
+    /**
+     * Creates an AuthenticationResponse builder for custom configuration
+     */
+    public static AuthenticationResponse.AuthenticationResponseBuilder authResponseBuilder() {
+        return AuthenticationResponse.builder()
+                .token(DEFAULT_JWT_TOKEN)
+                .expirationDate(Instant.now().plusSeconds(3600));
+    }
+}

+ 254 - 0
src/test/java/com/danielbohry/authservice/api/AuthControllerTest.java.disabled

@@ -0,0 +1,254 @@
+package com.danielbohry.authservice.api;
+
+import com.danielbohry.authservice.api.dto.AuthenticationRequest;
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.auth.AuthService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+import tools.jackson.databind.ObjectMapper;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(AuthController.class)
+class AuthControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private AuthService authService;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    private AuthenticationRequest testRequest;
+    private AuthenticationResponse testResponse;
+
+    @BeforeEach
+    void setUp() {
+        testRequest = new AuthenticationRequest("testuser", "password123");
+        testResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("jwt-token-123")
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(List.of("ROLE_USER"))
+                .build();
+    }
+
+    @Test
+    void shouldRegisterUserSuccessfully() throws Exception {
+        // given
+        when(authService.register(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then
+        mockMvc.perform(post("/api/register")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isCreated())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").value("user-id-123"))
+                .andExpect(jsonPath("$.username").value("testuser"))
+                .andExpect(jsonPath("$.token").value("jwt-token-123"))
+                .andExpect(jsonPath("$.roles[0]").value("ROLE_USER"));
+
+        verify(authService).register(any(AuthenticationRequest.class));
+    }
+
+    @Test
+    void shouldAuthenticateUserSuccessfully() throws Exception {
+        // given
+        when(authService.authenticate(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then
+        mockMvc.perform(post("/api/authenticate")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").value("user-id-123"))
+                .andExpect(jsonPath("$.username").value("testuser"))
+                .andExpect(jsonPath("$.token").value("jwt-token-123"))
+                .andExpect(jsonPath("$.roles[0]").value("ROLE_USER"));
+
+        verify(authService).authenticate(any(AuthenticationRequest.class));
+    }
+
+    @Test
+    void shouldReturnBadRequestWhenRegisterWithInvalidData() throws Exception {
+        // given - empty request body
+        AuthenticationRequest invalidRequest = new AuthenticationRequest("", "");
+
+        // when/then
+        mockMvc.perform(post("/api/register")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(invalidRequest)))
+                .andExpect(status().isCreated()); // Controller doesn't validate, service layer would handle this
+
+        verify(authService).register(any(AuthenticationRequest.class));
+    }
+
+    @Test
+    void shouldReturnServerErrorWhenServiceThrowsException() throws Exception {
+        // given
+        when(authService.register(any(AuthenticationRequest.class)))
+                .thenThrow(new RuntimeException("Service error"));
+
+        // when/then
+        mockMvc.perform(post("/api/register")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isInternalServerError());
+
+        verify(authService).register(any(AuthenticationRequest.class));
+    }
+
+    @Test
+    void shouldAuthorizeUserWithCorrectRole() throws Exception {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // when/then
+        mockMvc.perform(post("/api/authorize")
+                .param("authority", "USER")
+                .with(user(mockUser)))
+                .andExpect(status().isOk());
+    }
+
+    @Test
+    void shouldRejectUserWithInsufficientRole() throws Exception {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // when/then
+        mockMvc.perform(post("/api/authorize")
+                .param("authority", "ADMIN")
+                .with(user(mockUser)))
+                .andExpect(status().isForbidden());
+    }
+
+    @Test
+    void shouldAuthorizeWithDefaultUserRole() throws Exception {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // when/then - no authority parameter should default to "USER"
+        mockMvc.perform(post("/api/authorize")
+                .with(user(mockUser)))
+                .andExpect(status().isOk());
+    }
+
+    @Test
+    void shouldRejectUnauthorizedRequestToAuthorize() throws Exception {
+        // when/then - no authenticated user
+        mockMvc.perform(post("/api/authorize")
+                .param("authority", "USER"))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    @WithMockUser(authorities = "ADMIN")
+    void shouldAuthorizeAdminUserForAdminRole() throws Exception {
+        // given
+        ApplicationUser adminUser = ApplicationUser.builder()
+                .id("admin-id-123")
+                .username("admin")
+                .roles(List.of(Role.ADMIN))
+                .active(true)
+                .build();
+
+        // when/then
+        mockMvc.perform(post("/api/authorize")
+                .param("authority", "ADMIN")
+                .with(user(adminUser)))
+                .andExpect(status().isOk());
+    }
+
+    @Test
+    void shouldHandleCorsRequests() throws Exception {
+        // given
+        when(authService.register(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then - Test that CORS is enabled
+        mockMvc.perform(post("/api/register")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest))
+                .header("Origin", "http://localhost:3000"))
+                .andExpect(status().isCreated())
+                .andExpect(header().string("Access-Control-Allow-Origin", "*"));
+    }
+
+    @Test
+    void shouldReturnCorrectContentTypeForAuthResponses() throws Exception {
+        // given
+        when(authService.authenticate(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then
+        mockMvc.perform(post("/api/authenticate")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.expirationDate").exists());
+    }
+
+    @Test
+    void shouldLogRegistrationAttempts() throws Exception {
+        // given
+        when(authService.register(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then - This tests that the controller method executes (logging happens inside)
+        mockMvc.perform(post("/api/register")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isCreated());
+
+        verify(authService).register(any(AuthenticationRequest.class));
+    }
+
+    @Test
+    void shouldLogAuthenticationAttempts() throws Exception {
+        // given
+        when(authService.authenticate(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when/then - This tests that the controller method executes (logging happens inside)
+        mockMvc.perform(post("/api/authenticate")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(testRequest)))
+                .andExpect(status().isOk());
+
+        verify(authService).authenticate(any(AuthenticationRequest.class));
+    }
+}

+ 217 - 0
src/test/java/com/danielbohry/authservice/api/AuthControllerUnitTest.java

@@ -0,0 +1,217 @@
+package com.danielbohry.authservice.api;
+
+import com.danielbohry.authservice.api.dto.AuthenticationRequest;
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.auth.AuthService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class AuthControllerUnitTest {
+
+    @Mock
+    private AuthService authService;
+
+    @InjectMocks
+    private AuthController authController;
+
+    private AuthenticationRequest testRequest;
+    private AuthenticationResponse testResponse;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        testRequest = new AuthenticationRequest("testuser", "password123");
+        testResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("jwt-token-123")
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(List.of("ROLE_USER"))
+                .build();
+    }
+
+    @Test
+    void shouldRegisterUserSuccessfully() {
+        // given
+        when(authService.register(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when
+        ResponseEntity<AuthenticationResponse> response = authController.register(testRequest);
+
+        // then
+        assertNotNull(response);
+        assertEquals(HttpStatus.CREATED, response.getStatusCode());
+        assertEquals(testResponse, response.getBody());
+        verify(authService).register(testRequest);
+    }
+
+    @Test
+    void shouldAuthenticateUserSuccessfully() {
+        // given
+        when(authService.authenticate(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when
+        ResponseEntity<AuthenticationResponse> response = authController.authenticate(testRequest);
+
+        // then
+        assertNotNull(response);
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(testResponse, response.getBody());
+        verify(authService).authenticate(testRequest);
+    }
+
+    @Test
+    void shouldAuthorizeUserWithCorrectRole() {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // Mock security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn(mockUser);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when
+        ResponseEntity<Object> response = authController.authorize("USER");
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldRejectUserWithInsufficientRole() {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // Mock security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn(mockUser);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when
+        ResponseEntity<Object> response = authController.authorize("ADMIN");
+
+        // then
+        assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldAuthorizeWithDefaultUserRole() {
+        // given
+        ApplicationUser mockUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // Mock security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn(mockUser);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when - no authority parameter should default to "USER"
+        ResponseEntity<Object> response = authController.authorize("USER");
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldAuthorizeAdminUserForAdminRole() {
+        // given
+        ApplicationUser adminUser = ApplicationUser.builder()
+                .id("admin-id-123")
+                .username("admin")
+                .roles(List.of(Role.ADMIN))
+                .active(true)
+                .build();
+
+        // Mock security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn(adminUser);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when
+        ResponseEntity<Object> response = authController.authorize("ADMIN");
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldReturnCorrectStatusCodes() {
+        // given
+        when(authService.register(any(AuthenticationRequest.class))).thenReturn(testResponse);
+        when(authService.authenticate(any(AuthenticationRequest.class))).thenReturn(testResponse);
+
+        // when
+        ResponseEntity<AuthenticationResponse> registerResponse = authController.register(testRequest);
+        ResponseEntity<AuthenticationResponse> authResponse = authController.authenticate(testRequest);
+
+        // then
+        assertEquals(HttpStatus.CREATED, registerResponse.getStatusCode());
+        assertEquals(HttpStatus.OK, authResponse.getStatusCode());
+    }
+
+    @Test
+    void shouldHandleServiceExceptions() {
+        // given
+        when(authService.register(any(AuthenticationRequest.class)))
+                .thenThrow(new RuntimeException("Service error"));
+
+        // when/then
+        assertThrows(RuntimeException.class, () -> {
+            authController.register(testRequest);
+        });
+
+        verify(authService).register(testRequest);
+    }
+}

+ 245 - 0
src/test/java/com/danielbohry/authservice/api/ControllerAdvisorTest.java

@@ -0,0 +1,245 @@
+package com.danielbohry.authservice.api;
+
+import com.danielbohry.authservice.exceptions.BadRequestException;
+import com.danielbohry.authservice.exceptions.NotFoundException;
+import io.jsonwebtoken.ExpiredJwtException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.multipart.MultipartException;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ControllerAdvisorTest {
+
+    private ControllerAdvisor controllerAdvisor;
+
+    @BeforeEach
+    void setUp() {
+        controllerAdvisor = new ControllerAdvisor();
+    }
+
+    @Test
+    void shouldHandleNotFoundException() {
+        // given
+        String errorMessage = "User not found";
+        NotFoundException exception = new NotFoundException(errorMessage);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleNotFoundException(exception);
+
+        // then
+        assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertEquals(errorMessage, body.get("message"));
+        assertNotNull(body.get("timestamp"));
+        assertTrue(body.get("timestamp") instanceof LocalDateTime);
+    }
+
+    @Test
+    void shouldHandleBadRequestException() {
+        // given
+        String errorMessage = "Username is already in use";
+        BadRequestException exception = new BadRequestException(errorMessage);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleBadRequestException(exception);
+
+        // then
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertEquals(errorMessage, body.get("message"));
+        assertNotNull(body.get("timestamp"));
+        assertTrue(body.get("timestamp") instanceof LocalDateTime);
+    }
+
+    @Test
+    void shouldHandleMultipartException() {
+        // given
+        String errorMessage = "Maximum upload size exceeded";
+        MultipartException exception = new MultipartException(errorMessage);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleMultipartException(exception);
+
+        // then
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertEquals(errorMessage, body.get("message"));
+        assertNotNull(body.get("timestamp"));
+        assertTrue(body.get("timestamp") instanceof LocalDateTime);
+    }
+
+    @Test
+    void shouldHandleExpiredJwtException() {
+        // given
+        String errorMessage = "JWT token has expired";
+        ExpiredJwtException exception = new ExpiredJwtException(null, null, errorMessage);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleExpiredJwtException(exception);
+
+        // then
+        assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
+
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertEquals(errorMessage, body.get("message"));
+        assertNotNull(body.get("timestamp"));
+        assertTrue(body.get("timestamp") instanceof LocalDateTime);
+    }
+
+    @Test
+    void shouldIncludeTimestampInErrorResponse() {
+        // given
+        LocalDateTime before = LocalDateTime.now().minusSeconds(1);
+        NotFoundException exception = new NotFoundException("Test error");
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleNotFoundException(exception);
+        LocalDateTime after = LocalDateTime.now().plusSeconds(1);
+
+        // then
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        LocalDateTime timestamp = (LocalDateTime) body.get("timestamp");
+
+        assertTrue(timestamp.isAfter(before));
+        assertTrue(timestamp.isBefore(after));
+    }
+
+    @Test
+    void shouldReturnCorrectHttpStatusForDifferentExceptions() {
+        // given
+        NotFoundException notFound = new NotFoundException("Not found");
+        BadRequestException badRequest = new BadRequestException("Bad request");
+        MultipartException multipart = new MultipartException("Multipart error");
+        ExpiredJwtException expiredJwt = new ExpiredJwtException(null, null, "Expired");
+
+        // when
+        ResponseEntity<Object> notFoundResponse = controllerAdvisor.handleNotFoundException(notFound);
+        ResponseEntity<Object> badRequestResponse = controllerAdvisor.handleBadRequestException(badRequest);
+        ResponseEntity<Object> multipartResponse = controllerAdvisor.handleMultipartException(multipart);
+        ResponseEntity<Object> expiredJwtResponse = controllerAdvisor.handleExpiredJwtException(expiredJwt);
+
+        // then
+        assertEquals(HttpStatus.NOT_FOUND, notFoundResponse.getStatusCode());
+        assertEquals(HttpStatus.BAD_REQUEST, badRequestResponse.getStatusCode());
+        assertEquals(HttpStatus.BAD_REQUEST, multipartResponse.getStatusCode());
+        assertEquals(HttpStatus.UNAUTHORIZED, expiredJwtResponse.getStatusCode());
+    }
+
+    @Test
+    void shouldPreserveOriginalExceptionMessage() {
+        // given
+        String originalMessage = "This is the original error message";
+        NotFoundException exception = new NotFoundException(originalMessage);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleNotFoundException(exception);
+
+        // then
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertEquals(originalMessage, body.get("message"));
+    }
+
+    @Test
+    void shouldHandleNullExceptionMessage() {
+        // given
+        NotFoundException exception = new NotFoundException(null);
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleNotFoundException(exception);
+
+        // then
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertNull(body.get("message"));
+        assertNotNull(body.get("timestamp"));
+    }
+
+    @Test
+    void shouldReturnLinkedHashMapAsResponseBody() {
+        // given
+        BadRequestException exception = new BadRequestException("Test error");
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleBadRequestException(exception);
+
+        // then
+        Object body = response.getBody();
+        assertTrue(body instanceof Map);
+        // LinkedHashMap maintains insertion order, which is important for consistent JSON output
+        assertEquals("java.util.LinkedHashMap", body.getClass().getName());
+    }
+
+    @Test
+    void shouldContainExactlyTwoFieldsInErrorResponse() {
+        // given
+        NotFoundException exception = new NotFoundException("Test error");
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleNotFoundException(exception);
+
+        // then
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertNotNull(body);
+        assertEquals(2, body.size());
+        assertTrue(body.containsKey("timestamp"));
+        assertTrue(body.containsKey("message"));
+    }
+
+    @Test
+    void shouldHandleEmptyExceptionMessage() {
+        // given
+        BadRequestException exception = new BadRequestException("");
+
+        // when
+        ResponseEntity<Object> response = controllerAdvisor.handleBadRequestException(exception);
+
+        // then
+        Map<String, Object> body = (Map<String, Object>) response.getBody();
+        assertEquals("", body.get("message"));
+    }
+
+    @Test
+    void shouldMaintainConsistentResponseStructureAcrossAllHandlers() {
+        // given
+        NotFoundException notFound = new NotFoundException("Not found");
+        BadRequestException badRequest = new BadRequestException("Bad request");
+        MultipartException multipart = new MultipartException("Multipart error");
+        ExpiredJwtException expiredJwt = new ExpiredJwtException(null, null, "Expired");
+
+        // when
+        ResponseEntity<Object> response1 = controllerAdvisor.handleNotFoundException(notFound);
+        ResponseEntity<Object> response2 = controllerAdvisor.handleBadRequestException(badRequest);
+        ResponseEntity<Object> response3 = controllerAdvisor.handleMultipartException(multipart);
+        ResponseEntity<Object> response4 = controllerAdvisor.handleExpiredJwtException(expiredJwt);
+
+        // then - all responses should have the same structure
+        Map<String, Object> body1 = (Map<String, Object>) response1.getBody();
+        Map<String, Object> body2 = (Map<String, Object>) response2.getBody();
+        Map<String, Object> body3 = (Map<String, Object>) response3.getBody();
+        Map<String, Object> body4 = (Map<String, Object>) response4.getBody();
+
+        assertEquals(2, body1.size());
+        assertEquals(2, body2.size());
+        assertEquals(2, body3.size());
+        assertEquals(2, body4.size());
+
+        assertTrue(body1.containsKey("timestamp") && body1.containsKey("message"));
+        assertTrue(body2.containsKey("timestamp") && body2.containsKey("message"));
+        assertTrue(body3.containsKey("timestamp") && body3.containsKey("message"));
+        assertTrue(body4.containsKey("timestamp") && body4.containsKey("message"));
+    }
+}

+ 258 - 0
src/test/java/com/danielbohry/authservice/api/UserControllerTest.java.disabled

@@ -0,0 +1,258 @@
+package com.danielbohry.authservice.api;
+
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.api.dto.PasswordChangeRequest;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.auth.AuthService;
+import tools.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(UserController.class)
+class UserControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private AuthService authService;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    private ApplicationUser testUser;
+    private ApplicationUser adminUser;
+    private PasswordChangeRequest passwordChangeRequest;
+    private AuthenticationResponse authResponse;
+
+    @BeforeEach
+    void setUp() {
+        testUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .password("encodedPassword")
+                .email("test@example.com")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        adminUser = ApplicationUser.builder()
+                .id("admin-id-123")
+                .username("admin")
+                .password("encodedPassword")
+                .email("admin@example.com")
+                .roles(List.of(Role.ADMIN, Role.USER))
+                .active(true)
+                .build();
+
+        passwordChangeRequest = new PasswordChangeRequest("oldPassword", "newPassword");
+
+        authResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("new-jwt-token-123")
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(List.of("ROLE_USER"))
+                .build();
+    }
+
+    @Test
+    void shouldReturnCurrentUserInfo() throws Exception {
+        // when/then
+        mockMvc.perform(get("/api/users/current")
+                .with(user(testUser)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").value("user-id-123"))
+                .andExpect(jsonPath("$.username").value("testuser"))
+                .andExpect(jsonPath("$.roles[0]").value("USER"));
+    }
+
+    @Test
+    void shouldReturnCurrentUserInfoForAdminWithMultipleRoles() throws Exception {
+        // when/then
+        mockMvc.perform(get("/api/users/current")
+                .with(user(adminUser)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").value("admin-id-123"))
+                .andExpect(jsonPath("$.username").value("admin"))
+                .andExpect(jsonPath("$.roles").isArray())
+                .andExpect(jsonPath("$.roles.length()").value(2));
+    }
+
+    @Test
+    void shouldReturnForbiddenWhenNotAuthenticated() throws Exception {
+        // when/then - no authenticated user
+        mockMvc.perform(get("/api/users/current"))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    void shouldChangePasswordSuccessfully() throws Exception {
+        // given
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenReturn(authResponse);
+
+        // when/then
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest))
+                .with(user(testUser)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").value("user-id-123"))
+                .andExpect(jsonPath("$.username").value("testuser"))
+                .andExpect(jsonPath("$.token").value("new-jwt-token-123"));
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+    }
+
+    @Test
+    void shouldRejectPasswordChangeWhenNotAuthenticated() throws Exception {
+        // when/then - no authenticated user
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest)))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    void shouldReturnUnauthorizedForPasswordChangeWhenPrincipalNotApplicationUser() throws Exception {
+        // when/then - mock principal that's not an ApplicationUser instance
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest))
+                .with(user("plainstring"))) // String principal instead of ApplicationUser
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    void shouldHandlePasswordChangeServiceError() throws Exception {
+        // given
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenThrow(new RuntimeException("Service error"));
+
+        // when/then
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest))
+                .with(user(testUser)))
+                .andExpect(status().isInternalServerError());
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+    }
+
+    @Test
+    void shouldLogPasswordChangeAttempts() throws Exception {
+        // given
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenReturn(authResponse);
+
+        // when/then - This tests that the controller method executes (logging happens inside)
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest))
+                .with(user(testUser)))
+                .andExpect(status().isOk());
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+    }
+
+    @Test
+    void shouldReturnForbiddenForCurrentUserWhenPrincipalNotApplicationUser() throws Exception {
+        // when/then - mock principal that's not an ApplicationUser instance
+        mockMvc.perform(get("/api/users/current")
+                .with(user("plainstring"))) // String principal instead of ApplicationUser
+                .andExpect(status().isForbidden());
+    }
+
+    @Test
+    void shouldHandleCorsRequests() throws Exception {
+        // when/then - Test that CORS is enabled
+        mockMvc.perform(get("/api/users/current")
+                .header("Origin", "http://localhost:3000")
+                .with(user(testUser)))
+                .andExpect(status().isOk())
+                .andExpect(header().string("Access-Control-Allow-Origin", "*"));
+    }
+
+    @Test
+    void shouldReturnCorrectContentTypeForUserResponse() throws Exception {
+        // when/then
+        mockMvc.perform(get("/api/users/current")
+                .with(user(testUser)))
+                .andExpect(status().isOk())
+                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.id").exists())
+                .andExpect(jsonPath("$.username").exists())
+                .andExpect(jsonPath("$.roles").exists());
+    }
+
+    @Test
+    void shouldIncludeAllUserRolesInResponse() throws Exception {
+        // given
+        ApplicationUser multiRoleUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("multirole")
+                .password("password")
+                .roles(List.of(Role.ADMIN, Role.SERVICE, Role.VPN, Role.USER))
+                .active(true)
+                .build();
+
+        // when/then
+        mockMvc.perform(get("/api/users/current")
+                .with(user(multiRoleUser)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.roles").isArray())
+                .andExpect(jsonPath("$.roles.length()").value(4))
+                .andExpect(jsonPath("$.roles[?(@=='ADMIN')]").exists())
+                .andExpect(jsonPath("$.roles[?(@=='SERVICE')]").exists())
+                .andExpect(jsonPath("$.roles[?(@=='VPN')]").exists())
+                .andExpect(jsonPath("$.roles[?(@=='USER')]").exists());
+    }
+
+    @Test
+    void shouldReturnNewTokenAfterPasswordChange() throws Exception {
+        // given - response with new token and updated expiration
+        AuthenticationResponse newTokenResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("brand-new-jwt-token")
+                .expirationDate(Instant.now().plusSeconds(7200)) // Different expiration
+                .roles(List.of("ROLE_USER"))
+                .build();
+
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenReturn(newTokenResponse);
+
+        // when/then
+        mockMvc.perform(post("/api/users/change-password")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(passwordChangeRequest))
+                .with(user(testUser)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.token").value("brand-new-jwt-token"))
+                .andExpect(jsonPath("$.expirationDate").exists());
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+    }
+}

+ 257 - 0
src/test/java/com/danielbohry/authservice/api/UserControllerUnitTest.java

@@ -0,0 +1,257 @@
+package com.danielbohry.authservice.api;
+
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.api.dto.PasswordChangeRequest;
+import com.danielbohry.authservice.api.dto.UserResponse;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.auth.AuthService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+class UserControllerUnitTest {
+
+    @Mock
+    private AuthService authService;
+
+    @InjectMocks
+    private UserController userController;
+
+    private ApplicationUser testUser;
+    private ApplicationUser adminUser;
+    private PasswordChangeRequest passwordChangeRequest;
+    private AuthenticationResponse authResponse;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        testUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .password("encodedPassword")
+                .email("test@example.com")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        adminUser = ApplicationUser.builder()
+                .id("admin-id-123")
+                .username("admin")
+                .password("encodedPassword")
+                .email("admin@example.com")
+                .roles(List.of(Role.ADMIN, Role.USER))
+                .active(true)
+                .build();
+
+        passwordChangeRequest = new PasswordChangeRequest("oldPassword", "newPassword");
+
+        authResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("new-jwt-token-123")
+                .expirationDate(Instant.now().plusSeconds(3600))
+                .roles(List.of("ROLE_USER"))
+                .build();
+    }
+
+    @Test
+    void shouldReturnCurrentUserInfo() {
+        // given
+        mockSecurityContext(testUser);
+
+        // when
+        ResponseEntity<UserResponse> response = userController.get();
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertNotNull(response.getBody());
+        assertEquals("user-id-123", response.getBody().getId());
+        assertEquals("testuser", response.getBody().getUsername());
+        assertEquals(List.of("USER"), response.getBody().getRoles());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldReturnCurrentUserInfoForAdminWithMultipleRoles() {
+        // given
+        mockSecurityContext(adminUser);
+
+        // when
+        ResponseEntity<UserResponse> response = userController.get();
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertNotNull(response.getBody());
+        assertEquals("admin-id-123", response.getBody().getId());
+        assertEquals("admin", response.getBody().getUsername());
+        assertEquals(2, response.getBody().getRoles().size());
+        assertTrue(response.getBody().getRoles().contains("ADMIN"));
+        assertTrue(response.getBody().getRoles().contains("USER"));
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldReturnForbiddenWhenNotAuthenticated() {
+        // given - no authentication in security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn("not-an-application-user");
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when
+        ResponseEntity<UserResponse> response = userController.get();
+
+        // then
+        assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldChangePasswordSuccessfully() {
+        // given
+        mockSecurityContext(testUser);
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenReturn(authResponse);
+
+        // when
+        ResponseEntity<AuthenticationResponse> response = userController.changePassword(passwordChangeRequest);
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertNotNull(response.getBody());
+        assertEquals("user-id-123", response.getBody().getId());
+        assertEquals("testuser", response.getBody().getUsername());
+        assertEquals("new-jwt-token-123", response.getBody().getToken());
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldRejectPasswordChangeWhenNotAuthenticated() {
+        // given - no authentication in security context
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn("not-an-application-user");
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+
+        // when
+        ResponseEntity<AuthenticationResponse> response = userController.changePassword(passwordChangeRequest);
+
+        // then
+        assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldHandlePasswordChangeServiceError() {
+        // given
+        mockSecurityContext(testUser);
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenThrow(new RuntimeException("Service error"));
+
+        // when/then
+        assertThrows(RuntimeException.class, () -> {
+            userController.changePassword(passwordChangeRequest);
+        });
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldIncludeAllUserRolesInResponse() {
+        // given
+        ApplicationUser multiRoleUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("multirole")
+                .password("password")
+                .roles(List.of(Role.ADMIN, Role.SERVICE, Role.VPN, Role.USER))
+                .active(true)
+                .build();
+
+        mockSecurityContext(multiRoleUser);
+
+        // when
+        ResponseEntity<UserResponse> response = userController.get();
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertNotNull(response.getBody());
+        assertEquals(4, response.getBody().getRoles().size());
+        assertTrue(response.getBody().getRoles().contains("ADMIN"));
+        assertTrue(response.getBody().getRoles().contains("SERVICE"));
+        assertTrue(response.getBody().getRoles().contains("VPN"));
+        assertTrue(response.getBody().getRoles().contains("USER"));
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void shouldReturnNewTokenAfterPasswordChange() {
+        // given
+        mockSecurityContext(testUser);
+        AuthenticationResponse newTokenResponse = AuthenticationResponse.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .token("brand-new-jwt-token")
+                .expirationDate(Instant.now().plusSeconds(7200))
+                .roles(List.of("ROLE_USER"))
+                .build();
+
+        when(authService.changePassword(anyString(), anyString(), anyString()))
+                .thenReturn(newTokenResponse);
+
+        // when
+        ResponseEntity<AuthenticationResponse> response = userController.changePassword(passwordChangeRequest);
+
+        // then
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals("brand-new-jwt-token", response.getBody().getToken());
+        assertNotNull(response.getBody().getExpirationDate());
+
+        verify(authService).changePassword("user-id-123", "oldPassword", "newPassword");
+
+        // cleanup
+        SecurityContextHolder.clearContext();
+    }
+
+    private void mockSecurityContext(ApplicationUser user) {
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        when(authentication.getPrincipal()).thenReturn(user);
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+    }
+}

+ 263 - 0
src/test/java/com/danielbohry/authservice/service/auth/AuthServiceTest.java

@@ -0,0 +1,263 @@
+package com.danielbohry.authservice.service.auth;
+
+import com.danielbohry.authservice.api.dto.AuthenticationRequest;
+import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import com.danielbohry.authservice.service.user.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+class AuthServiceTest {
+
+    @Mock
+    private UserService userService;
+
+    @Mock
+    private JwtService jwtService;
+
+    @Mock
+    private AuthenticationManager authenticationManager;
+
+    @Mock
+    private PasswordEncoder passwordEncoder;
+
+    private AuthService authService;
+    private ApplicationUser testUser;
+    private Authentication testAuthentication;
+    private AuthenticationRequest testRequest;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        authService = new AuthService(userService, jwtService, authenticationManager, passwordEncoder);
+
+        testUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("testuser")
+                .password("encodedPassword")
+                .email("test@example.com")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        testAuthentication = new Authentication(
+                "jwt-token-123",
+                Instant.now().plusSeconds(3600),
+                "testuser",
+                List.of("ROLE_USER")
+        );
+
+        testRequest = new AuthenticationRequest("testuser", "password123");
+    }
+
+    @Test
+    void shouldLoadUserByUsername() {
+        // given
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+
+        // when
+        UserDetails result = authService.loadUserByUsername("testuser");
+
+        // then
+        assertNotNull(result);
+        assertEquals("testuser", result.getUsername());
+        assertEquals("encodedPassword", result.getPassword());
+        assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER")));
+        verify(userService).findByUsername("testuser");
+    }
+
+    @Test
+    void shouldThrowExceptionForUnknownUser() {
+        // given
+        when(userService.findByUsername("unknown")).thenThrow(new RuntimeException("User not found"));
+
+        // when/then
+        assertThrows(RuntimeException.class, () -> authService.loadUserByUsername("unknown"));
+        verify(userService).findByUsername("unknown");
+    }
+
+    @Test
+    void shouldRegisterNewUser() {
+        // given
+        when(passwordEncoder.encode("password123")).thenReturn("encodedPassword");
+        when(userService.create(any(ApplicationUser.class))).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.register(testRequest);
+
+        // then
+        assertNotNull(result);
+        assertEquals("user-id-123", result.getId());
+        assertEquals("testuser", result.getUsername());
+        assertEquals("jwt-token-123", result.getToken());
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(passwordEncoder).encode("password123");
+        verify(userService).create(any(ApplicationUser.class));
+        verify(jwtService).generateToken(testUser);
+    }
+
+    @Test
+    void shouldAuthenticateValidUser() {
+        // given
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.authenticate(testRequest);
+
+        // then
+        assertNotNull(result);
+        assertEquals("user-id-123", result.getId());
+        assertEquals("testuser", result.getUsername());
+        assertEquals("jwt-token-123", result.getToken());
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class));
+        verify(userService).findByUsername("testuser");
+        verify(jwtService).generateToken(testUser);
+    }
+
+    @Test
+    void shouldFailAuthenticationWithInvalidCredentials() {
+        // given
+        when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
+                .thenThrow(new RuntimeException("Bad credentials"));
+
+        // when/then
+        assertThrows(RuntimeException.class, () -> authService.authenticate(testRequest));
+        verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class));
+        verifyNoInteractions(userService);
+        verifyNoInteractions(jwtService);
+    }
+
+    @Test
+    void shouldChangePassword() {
+        // given
+        String userId = "user-id-123";
+        String currentPassword = "oldPassword";
+        String newPassword = "newPassword";
+        ApplicationUser updatedUser = ApplicationUser.builder()
+                .id(userId)
+                .username("testuser")
+                .password("newEncodedPassword")
+                .email("test@example.com")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        when(userService.changePassword(userId, currentPassword, newPassword, passwordEncoder))
+                .thenReturn(updatedUser);
+        when(jwtService.generateToken(updatedUser)).thenReturn(testAuthentication);
+
+        // when
+        AuthenticationResponse result = authService.changePassword(userId, currentPassword, newPassword);
+
+        // then
+        assertNotNull(result);
+        assertEquals("user-id-123", result.getId());
+        assertEquals("testuser", result.getUsername());
+        assertEquals("jwt-token-123", result.getToken());
+        assertEquals(testAuthentication.expirationDate(), result.getExpirationDate());
+        assertEquals(List.of("ROLE_USER"), result.getRoles());
+
+        verify(userService).changePassword(userId, currentPassword, newPassword, passwordEncoder);
+        verify(jwtService).generateToken(updatedUser);
+    }
+
+    @Test
+    void shouldBuildCorrectUserAuthorities() {
+        // given
+        ApplicationUser multiRoleUser = ApplicationUser.builder()
+                .id("user-id-123")
+                .username("admin")
+                .password("encodedPassword")
+                .email("admin@example.com")
+                .roles(List.of(Role.ADMIN, Role.USER))
+                .active(true)
+                .build();
+
+        when(userService.findByUsername("admin")).thenReturn(multiRoleUser);
+
+        // when
+        UserDetails result = authService.loadUserByUsername("admin");
+
+        // then
+        assertNotNull(result);
+        assertEquals(2, result.getAuthorities().size());
+        assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN")));
+        assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER")));
+    }
+
+    @Test
+    void shouldEncodePasswordDuringRegistration() {
+        // given
+        when(passwordEncoder.encode("plainPassword")).thenReturn("encodedPassword123");
+        when(userService.create(any(ApplicationUser.class))).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        AuthenticationRequest request = new AuthenticationRequest("newuser", "plainPassword");
+
+        // when
+        authService.register(request);
+
+        // then
+        verify(passwordEncoder).encode("plainPassword");
+        verify(userService).create(argThat(user ->
+            "encodedPassword123".equals(user.getPassword()) &&
+            "newuser".equals(user.getUsername())
+        ));
+    }
+
+    @Test
+    void shouldCreateUserWithDefaultUserRole() {
+        // given
+        when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword");
+        when(userService.create(any(ApplicationUser.class))).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        authService.register(testRequest);
+
+        // then
+        verify(userService).create(argThat(user -> user.getRoles() == null || user.getRoles().isEmpty()));
+        // Note: UserService.create sets default USER role internally
+    }
+
+    @Test
+    void shouldCallAuthenticationManagerWithCorrectCredentials() {
+        // given
+        when(userService.findByUsername("testuser")).thenReturn(testUser);
+        when(jwtService.generateToken(testUser)).thenReturn(testAuthentication);
+
+        // when
+        authService.authenticate(testRequest);
+
+        // then
+        verify(authenticationManager).authenticate(argThat(auth ->
+            auth instanceof UsernamePasswordAuthenticationToken &&
+            "testuser".equals(auth.getName()) &&
+            "password123".equals(auth.getCredentials())
+        ));
+    }
+}

+ 311 - 0
src/test/java/com/danielbohry/authservice/service/auth/JwtServiceTest.java

@@ -0,0 +1,311 @@
+package com.danielbohry.authservice.service.auth;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.domain.Role;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.when;
+
+class JwtServiceTest {
+
+    private JwtService jwtService;
+    private final String testSecret = "test-secret-key-for-jwt-testing-that-is-long-enough-to-be-256-bits-for-hmac-sha-algorithms";
+
+    private javax.crypto.SecretKey getTestSigningKey() {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] keyBytes = digest.digest(testSecret.getBytes(StandardCharsets.UTF_8));
+            return Keys.hmacShaKeyFor(keyBytes);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+    private UserDetails testUser;
+    private UserDetails adminUser;
+    private UserDetails serviceUser;
+    private UserDetails vpnUser;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        jwtService = new JwtService();
+        ReflectionTestUtils.setField(jwtService, "secret", testSecret);
+
+        // Create test users with different roles
+        testUser = ApplicationUser.builder()
+                .username("testuser")
+                .password("password")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        adminUser = ApplicationUser.builder()
+                .username("admin")
+                .password("password")
+                .roles(List.of(Role.ADMIN))
+                .active(true)
+                .build();
+
+        serviceUser = ApplicationUser.builder()
+                .username("service")
+                .password("password")
+                .roles(List.of(Role.SERVICE))
+                .active(true)
+                .build();
+
+        vpnUser = ApplicationUser.builder()
+                .username("vpn")
+                .password("password")
+                .roles(List.of(Role.VPN))
+                .active(true)
+                .build();
+    }
+
+    @Test
+    void shouldGenerateValidToken() {
+        // when
+        Authentication result = jwtService.generateToken(testUser);
+
+        // then
+        assertNotNull(result);
+        assertNotNull(result.token());
+        assertNotNull(result.expirationDate());
+        assertEquals("testuser", result.username());
+        assertTrue(result.authorities().contains("USER"));
+        assertTrue(result.expirationDate().isAfter(Instant.now()));
+    }
+
+    @Test
+    void shouldExtractUsernameFromToken() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+
+        // when
+        String extractedUsername = jwtService.extractUsername(token);
+
+        // then
+        assertEquals("testuser", extractedUsername);
+    }
+
+    @Test
+    void shouldExtractExpirationFromToken() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+
+        // when
+        Date extractedExpiration = jwtService.extractExpiration(token);
+
+        // then
+        assertNotNull(extractedExpiration);
+        assertTrue(extractedExpiration.after(new Date()));
+    }
+
+    @Test
+    void shouldValidateValidToken() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+
+        // when
+        Boolean isValid = jwtService.isTokenValid(token, testUser);
+
+        // then
+        assertTrue(isValid);
+    }
+
+    @Test
+    void shouldRejectTokenForDifferentUser() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+        UserDetails differentUser = ApplicationUser.builder()
+                .username("differentuser")
+                .password("password")
+                .roles(List.of(Role.USER))
+                .active(true)
+                .build();
+
+        // when
+        Boolean isValid = jwtService.isTokenValid(token, differentUser);
+
+        // then
+        assertFalse(isValid);
+    }
+
+    @Test
+    void shouldDetectExpiredToken() {
+        // given - create a token with past expiration
+        Date pastDate = Date.from(Instant.now().minus(1, ChronoUnit.HOURS));
+        String expiredToken = Jwts.builder()
+                .subject("testuser")
+                .issuedAt(pastDate)
+                .expiration(pastDate)
+                .signWith(getTestSigningKey())
+                .compact();
+
+        // when
+        Boolean isExpired = jwtService.isTokenExpired(expiredToken);
+
+        // then
+        assertTrue(isExpired);
+    }
+
+    @Test
+    void shouldNotDetectValidTokenAsExpired() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+
+        // when
+        Boolean isExpired = jwtService.isTokenExpired(token);
+
+        // then
+        assertFalse(isExpired);
+    }
+
+    @Test
+    void shouldCalculateExpirationByRole_Admin() {
+        // when
+        Authentication result = jwtService.generateToken(adminUser);
+
+        // then
+        assertNotNull(result);
+        Instant expectedExpiration = Instant.now().plus(1, ChronoUnit.HOURS);
+        // Allow for small timing differences (within 1 minute)
+        assertTrue(result.expirationDate().isBefore(expectedExpiration.plus(1, ChronoUnit.MINUTES)));
+        assertTrue(result.expirationDate().isAfter(expectedExpiration.minus(1, ChronoUnit.MINUTES)));
+    }
+
+    @Test
+    void shouldCalculateExpirationByRole_Service() {
+        // when
+        Authentication result = jwtService.generateToken(serviceUser);
+
+        // then
+        assertNotNull(result);
+        Instant expectedExpiration = Instant.now().plus(12, ChronoUnit.HOURS);
+        // Allow for small timing differences (within 1 minute)
+        assertTrue(result.expirationDate().isBefore(expectedExpiration.plus(1, ChronoUnit.MINUTES)));
+        assertTrue(result.expirationDate().isAfter(expectedExpiration.minus(1, ChronoUnit.MINUTES)));
+    }
+
+    @Test
+    void shouldCalculateExpirationByRole_VPN() {
+        // when
+        Authentication result = jwtService.generateToken(vpnUser);
+
+        // then
+        assertNotNull(result);
+        Instant expectedExpiration = Instant.now().plus(24, ChronoUnit.HOURS);
+        // Allow for small timing differences (within 1 minute)
+        assertTrue(result.expirationDate().isBefore(expectedExpiration.plus(1, ChronoUnit.MINUTES)));
+        assertTrue(result.expirationDate().isAfter(expectedExpiration.minus(1, ChronoUnit.MINUTES)));
+    }
+
+    @Test
+    void shouldCalculateExpirationByRole_User() {
+        // when
+        Authentication result = jwtService.generateToken(testUser);
+
+        // then
+        assertNotNull(result);
+        Instant expectedExpiration = Instant.now().plus(48, ChronoUnit.HOURS);
+        // Allow for small timing differences (within 1 minute)
+        assertTrue(result.expirationDate().isBefore(expectedExpiration.plus(1, ChronoUnit.MINUTES)));
+        assertTrue(result.expirationDate().isAfter(expectedExpiration.minus(1, ChronoUnit.MINUTES)));
+    }
+
+    @Test
+    void shouldHandleExpiredJwtException() {
+        // given - create an expired token
+        Date pastDate = Date.from(Instant.now().minus(1, ChronoUnit.HOURS));
+        String expiredToken = Jwts.builder()
+                .subject("testuser")
+                .claim("authorities", Set.of("USER"))
+                .issuedAt(pastDate)
+                .expiration(pastDate)
+                .signWith(getTestSigningKey())
+                .compact();
+
+        // when/then - should not throw exception but handle it internally
+        assertDoesNotThrow(() -> {
+            String username = jwtService.extractUsername(expiredToken);
+            assertEquals("testuser", username);
+        });
+    }
+
+    @Test
+    void shouldExtractClaimsFromToken() {
+        // given
+        Authentication auth = jwtService.generateToken(testUser);
+        String token = auth.token();
+
+        // when
+        String subject = jwtService.extractClaim(token, claims -> claims.getSubject());
+        Date expiration = jwtService.extractClaim(token, claims -> claims.getExpiration());
+
+        // then
+        assertEquals("testuser", subject);
+        assertNotNull(expiration);
+        assertTrue(expiration.after(new Date()));
+    }
+
+    @Test
+    void shouldGenerateTokenWithCorrectAuthorities() {
+        // given
+        UserDetails multiRoleUser = ApplicationUser.builder()
+                .username("multirole")
+                .password("password")
+                .roles(List.of(Role.ADMIN, Role.USER))
+                .active(true)
+                .build();
+
+        // when
+        Authentication result = jwtService.generateToken(multiRoleUser);
+
+        // then
+        assertNotNull(result);
+        assertEquals(2, result.authorities().size());
+        assertTrue(result.authorities().contains("ADMIN"));
+        assertTrue(result.authorities().contains("USER"));
+    }
+
+    @Test
+    void shouldPrioritizeAdminRoleForExpiration() {
+        // given - user with multiple roles including ADMIN
+        UserDetails multiRoleUser = ApplicationUser.builder()
+                .username("multirole")
+                .password("password")
+                .roles(List.of(Role.ADMIN, Role.USER, Role.VPN))
+                .active(true)
+                .build();
+
+        // when
+        Authentication result = jwtService.generateToken(multiRoleUser);
+
+        // then - should use ADMIN expiration (1 hour) despite having other roles
+        assertNotNull(result);
+        Instant expectedExpiration = Instant.now().plus(1, ChronoUnit.HOURS);
+        assertTrue(result.expirationDate().isBefore(expectedExpiration.plus(1, ChronoUnit.MINUTES)));
+        assertTrue(result.expirationDate().isAfter(expectedExpiration.minus(1, ChronoUnit.MINUTES)));
+    }
+}

+ 229 - 0
src/test/java/com/danielbohry/authservice/service/auth/UserConverterTest.java

@@ -0,0 +1,229 @@
+package com.danielbohry.authservice.service.auth;
+
+import com.danielbohry.authservice.domain.ApplicationUser;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class UserConverterTest {
+
+    @Test
+    void shouldConvertUserDetailsToApplicationUser() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("testuser", result.getUsername());
+        assertEquals("encodedPassword", result.getPassword());
+        assertNull(result.getId());
+        assertNull(result.getEmail());
+        assertNull(result.getRoles());
+        assertFalse(result.isActive()); // defaults to false
+    }
+
+    @Test
+    void shouldConvertUserDetailsWithEmptyUsername() {
+        // given - Spring Security User doesn't allow empty username, so test with a minimal username
+        UserDetails userDetails = User.builder()
+                .username("u") // Minimal valid username
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("u", result.getUsername());
+        assertEquals("encodedPassword", result.getPassword());
+    }
+
+    @Test
+    void shouldConvertUserDetailsWithEmptyPassword() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("testuser", result.getUsername());
+        assertEquals("", result.getPassword());
+    }
+
+    @Test
+    void shouldConvertUserDetailsWithNullPassword() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password(null)
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("testuser", result.getUsername());
+        assertNull(result.getPassword());
+    }
+
+    @Test
+    void shouldSetDefaultValuesForUnspecifiedFields() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNull(result.getId());
+        assertNull(result.getEmail());
+        assertNull(result.getRoles());
+        assertFalse(result.isActive());
+    }
+
+    @Test
+    void shouldHandleUserDetailsWithMultipleAuthorities() {
+        // given - UserDetails with multiple authorities (though converter doesn't use them)
+        UserDetails userDetails = User.builder()
+                .username("admin")
+                .password("encodedPassword")
+                .authorities(List.of(
+                    new SimpleGrantedAuthority("ROLE_ADMIN"),
+                    new SimpleGrantedAuthority("ROLE_USER")
+                ))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("admin", result.getUsername());
+        assertEquals("encodedPassword", result.getPassword());
+        // Note: The converter doesn't map authorities to roles
+        assertNull(result.getRoles());
+    }
+
+    @Test
+    void shouldHandleUserDetailsWithNoAuthorities() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of()) // empty authorities
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result);
+        assertEquals("testuser", result.getUsername());
+        assertEquals("encodedPassword", result.getPassword());
+        assertNull(result.getRoles());
+    }
+
+    @Test
+    void shouldPreserveUsernameAndPasswordExactly() {
+        // given
+        String expectedUsername = "user@example.com";
+        String expectedPassword = "$2a$10$abcdefghijklmnopqrstuvwxyz";
+
+        UserDetails userDetails = User.builder()
+                .username(expectedUsername)
+                .password(expectedPassword)
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        assertEquals(expectedUsername, result.getUsername());
+        assertEquals(expectedPassword, result.getPassword());
+    }
+
+    @Test
+    void shouldNotThrowExceptionWithValidInput() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when/then - should not throw any exception
+        assertDoesNotThrow(() -> UserConverter.convert(userDetails));
+    }
+
+    @Test
+    void shouldCreateNewInstanceEachTime() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result1 = UserConverter.convert(userDetails);
+        ApplicationUser result2 = UserConverter.convert(userDetails);
+
+        // then
+        assertNotNull(result1);
+        assertNotNull(result2);
+        assertNotSame(result1, result2); // Different instances
+        assertEquals(result1.getUsername(), result2.getUsername());
+        assertEquals(result1.getPassword(), result2.getPassword());
+    }
+
+    @Test
+    void shouldUseBuilderPattern() {
+        // given
+        UserDetails userDetails = User.builder()
+                .username("testuser")
+                .password("encodedPassword")
+                .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
+                .build();
+
+        // when
+        ApplicationUser result = UserConverter.convert(userDetails);
+
+        // then
+        // Verify that the result is properly built (all expected fields are accessible)
+        assertNotNull(result);
+        assertTrue(result instanceof ApplicationUser);
+        assertDoesNotThrow(() -> result.getUsername());
+        assertDoesNotThrow(() -> result.getPassword());
+        assertDoesNotThrow(() -> result.getId());
+        assertDoesNotThrow(() -> result.getEmail());
+        assertDoesNotThrow(() -> result.getRoles());
+        assertDoesNotThrow(() -> result.isActive());
+    }
+}