From 9056634b9673327777282b7369c926c643d3e6c0 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Mon, 28 Sep 2026 14:36:29 -0300 Subject: [PATCH] fix: recover expired sessions instead of failing silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs left open through sleep or browser throttling missed the refresh interval, so every request failed with auth errors while the private layout kept rendering. Now the client revalidates the session on visibilitychange/focus/online, any 401/403 on an authenticated request signs out and redirects to login preserving the intended path, and the server lets the refresh endpoint exchange tokens expired up to 12 hours ago so waking tabs resume without a forced re-login. Fixes #93 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- client/src/__test__/api-service/api.test.ts | 59 +++++++++ .../__test__/context/AuthProvider.test.tsx | 91 +++++++++++++- client/src/api-service/api.ts | 25 +++- client/src/context/AuthProvider.tsx | 61 ++++++++-- .../filter/JwtAuthenticationFilter.java | 12 +- .../server/service/JwtService.java | 2 + .../server/service/impl/JwtServiceImpl.java | 32 ++++- .../filter/JwtAuthenticationFilterTest.java | 112 ++++++++++++++++++ .../service/impl/JwtServiceImplTest.java | 93 +++++++++++++++ 9 files changed, 464 insertions(+), 23 deletions(-) create mode 100644 client/src/__test__/api-service/api.test.ts create mode 100644 server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java diff --git a/client/src/__test__/api-service/api.test.ts b/client/src/__test__/api-service/api.test.ts new file mode 100644 index 00000000..8c3072e3 --- /dev/null +++ b/client/src/__test__/api-service/api.test.ts @@ -0,0 +1,59 @@ +// api.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import api, { setUnauthorizedHandler } from '../../api-service/api'; + +function jsonErrorResponse(status: number): Response { + return { + ok: false, + status, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ message: 'Unauthorized' }) + } as unknown as Response; +} + +describe('api unauthorized handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + setUnauthorizedHandler(undefined); + }); + + it('invokes the unauthorized handler on 401 for authenticated requests', async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401)); + + await expect(api.getJSON('/api/rest/home')).rejects.toThrow('Unauthorized'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('invokes the unauthorized handler on 403 for authenticated requests', async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(403)); + + await expect(api.getJSON('/api/rest/home')).rejects.toThrow('Unauthorized'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not invoke the handler for unauthenticated requests', async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401)); + + await expect(api.getJSONNoAuth('/api/public/notes/abc')).rejects.toThrow('Unauthorized'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not invoke the handler for sign-in failures', async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.mocked(global.fetch).mockResolvedValue(jsonErrorResponse(401)); + + await expect(api.postJSON('/api/auth/sign-in', {})).rejects.toThrow('Unauthorized'); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/__test__/context/AuthProvider.test.tsx b/client/src/__test__/context/AuthProvider.test.tsx index 8c082d93..a6db2dc3 100644 --- a/client/src/__test__/context/AuthProvider.test.tsx +++ b/client/src/__test__/context/AuthProvider.test.tsx @@ -5,8 +5,8 @@ import { render, waitFor, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import AuthProvider from '../../context/AuthProvider'; import AuthContext, { AuthContextData } from '../../context/AuthContext'; -import api from '../../api-service/api'; -import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants'; +import api, { setUnauthorizedHandler } from '../../api-service/api'; +import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../../app-constants/app-constants'; import ApiConfig from '../../api-service/apiConfig'; // Mock the API service methods. @@ -351,4 +351,91 @@ describe('AuthProvider', () => { expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User'); }); }); + + const setupSignedInSession = async () => { + const fakeTokenResponse = { token: 'refresh-token' }; + const fakeCurrentUser = { + userId: '789', + name: 'Refreshed User', + email: 'refreshed@example.com', + admin: false, + createdAt: new Date().toISOString(), + gravatarImageUrl: 'http://dummyimage.com', + lang: 'en', + lastLogin: new Date().toISOString() + }; + + vi.spyOn(api, 'getJSON').mockImplementation(async (url: string) => { + if (url === ApiConfig.refreshTokenUrl) { + return fakeTokenResponse; + } + if (url === ApiConfig.currentUserUrl) { + return fakeCurrentUser; + } + return undefined; + }); + localStorage.setItem(API_TOKEN, 'dummy'); + + const renderResult = render( + + + + ); + + await waitFor(() => + expect(renderResult.getByTestId('signed').textContent).toBe('true') + ); + return renderResult; + }; + + const refreshTokenCalls = () => + vi.mocked(api.getJSON).mock.calls.filter(call => call[0] === ApiConfig.refreshTokenUrl); + + it('should sign out and preserve the redirect path when a request is rejected as unauthorized', async () => { + const { getByTestId } = await setupSignedInSession(); + + const registered = vi.mocked(setUnauthorizedHandler).mock.calls.at(-1)?.[0]; + expect(registered).toBeTypeOf('function'); + + act(() => { + registered!(); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('false') + ); + expect(getByTestId('user').textContent).toBe('none'); + expect(localStorage.getItem(API_TOKEN)).toBeNull(); + expect(localStorage.getItem(REDIRECT_PATH)).toBe(window.location.pathname); + }); + + it('should refresh the session when the window regains focus', async () => { + await setupSignedInSession(); + const callsBefore = refreshTokenCalls().length; + + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + await waitFor(() => + expect(refreshTokenCalls().length).toBeGreaterThan(callsBefore) + ); + expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token'); + }); + + it('should sign out when the session refresh fails after wake', async () => { + const { getByTestId } = await setupSignedInSession(); + + vi.mocked(api.getJSON).mockRejectedValue(new Error('Unauthorized')); + + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + await waitFor(() => + expect(getByTestId('signed').textContent).toBe('false') + ); + expect(localStorage.getItem(API_TOKEN)).toBeNull(); + expect(localStorage.getItem(REDIRECT_PATH)).toBe(window.location.pathname); + }); }); diff --git a/client/src/api-service/api.ts b/client/src/api-service/api.ts index f40f196f..15bfa7f2 100644 --- a/client/src/api-service/api.ts +++ b/client/src/api-service/api.ts @@ -1,5 +1,19 @@ import { API_TOKEN } from '../app-constants/app-constants'; +type UnauthorizedHandler = () => void; + +let unauthorizedHandler: UnauthorizedHandler | undefined; + +/** + * Registers a callback invoked when an authenticated request is rejected with 401 or 403. + * Pass `undefined` to unregister. + * + * @param {UnauthorizedHandler | undefined} handler The callback to invoke on auth failures. + */ +export function setUnauthorizedHandler(handler: UnauthorizedHandler | undefined): void { + unauthorizedHandler = handler; +} + /** * Retrieves the API token from local storage. * @@ -50,7 +64,7 @@ function handleError(httpStatusCode: number) { throw new Error('Unknown error'); } -async function handleResponse(response: Response) { +async function handleResponse(response: Response, authenticated: boolean = true) { // Successful responses if (response?.ok) { const codesToIgnore: number[] = [204]; @@ -62,6 +76,9 @@ async function handleResponse(response: Response) { // Error responses if (response) { + if (authenticated && (response.status === 401 || response.status === 403)) { + unauthorizedHandler?.(); + } const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { const data = await response.json(); @@ -89,12 +106,12 @@ const api = { getJSONNoAuth: async (url: string) => { const response = await fetch(url, getRequestInit('GET', {}, false)); - return handleResponse(response); + return handleResponse(response, false); }, postJSON: async (url: string, payload: object) => { const response = await fetch(url, getRequestInit('POST', payload, isAddAuth(url))); - return handleResponse(response); + return handleResponse(response, isAddAuth(url)); }, patchJSON: async (url: string, payload: object) => { @@ -104,7 +121,7 @@ const api = { putJSON: async (url: string, payload: object) => { const response = await fetch(url, getRequestInit('PUT', payload, isAddAuth(url))); - return handleResponse(response); + return handleResponse(response, isAddAuth(url)); }, deleteNoContent: async (url: string) => { diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx index c0e1d9b1..f8de8e32 100644 --- a/client/src/context/AuthProvider.tsx +++ b/client/src/context/AuthProvider.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import AuthContext, { AuthContextData } from './AuthContext'; import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../app-constants/app-constants'; import { SignInResponse } from '../types/SigninResponse'; -import api from '../api-service/api'; +import api, { setUnauthorizedHandler } from '../api-service/api'; import ApiConfig from '../api-service/apiConfig'; import { UserResponse } from '../types/UserResponse'; import { UserRegistration } from '../types/UserRegistration'; @@ -36,11 +36,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro else if (e) { console.warn(e); } - // Clear stored client id and name - localStorage.clear(); - localStorage.setItem(REDIRECT_PATH, pathname); - setUser(undefined); - setSigned(false); + handleSessionExpired(pathname); } return undefined; }; @@ -115,6 +111,18 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro clearHomeCache(); }; + const handleSessionExpired = (pathname: string): void => { + signOut(); + if (pathname) { + localStorage.setItem(REDIRECT_PATH, pathname); + } + }; + + useEffect(() => { + setUnauthorizedHandler(() => handleSessionExpired(window.location.pathname)); + return () => setUnauthorizedHandler(undefined); + }, []); + useEffect(() => { checkCurrentAuthUser(window.location.pathname) .catch(e => console.error(e)) @@ -123,16 +131,43 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro useEffect(() => { if (!signed) return; + const TWENTY_FIVE_MINUTES = 25 * 60 * 1000; - const intervalId = setInterval(() => { + const WAKE_REFRESH_THROTTLE = 60 * 1000; + let lastWakeRefreshAt = 0; + + const refreshSession = () => { checkCurrentAuthUser(window.location.pathname).catch(() => { - setSigned(false); - setUser(undefined); - localStorage.clear(); - clearHomeCache(); + handleSessionExpired(window.location.pathname); }); - }, TWENTY_FIVE_MINUTES); - return () => clearInterval(intervalId); + }; + + // Timers do not fire while the machine sleeps or the tab is frozen, so + // revalidate the session as soon as the user comes back to the tab. + const refreshOnWake = () => { + const now = Date.now(); + if (now - lastWakeRefreshAt < WAKE_REFRESH_THROTTLE) return; + lastWakeRefreshAt = now; + refreshSession(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refreshOnWake(); + } + }; + + const intervalId = setInterval(refreshSession, TWENTY_FIVE_MINUTES); + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('focus', refreshOnWake); + window.addEventListener('online', refreshOnWake); + + return () => { + clearInterval(intervalId); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('focus', refreshOnWake); + window.removeEventListener('online', refreshOnWake); + }; }, [signed]); const updateUser = (userUpdated: UserResponse): void => { diff --git a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java index 38931535..a1cf68fb 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java +++ b/server/src/main/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilter.java @@ -21,6 +21,8 @@ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String REFRESH_PATH = "/rest/user-sessions/refresh"; + private final UserService userService; private final JwtService jwtService; @@ -67,7 +69,15 @@ protected void doFilterInternal( UserDetails user = userService.userDetailsService().loadUserByUsername(email); - if (!jwtService.validateTokenAndUser(jwtToken, user)) { + boolean valid = jwtService.validateTokenAndUser(jwtToken, user); + + // Allow a recently expired token to be exchanged only on the refresh endpoint, so a + // waking or previously frozen tab can resume its session without a full re-login. + if (!valid && REFRESH_PATH.equals(requestPath)) { + valid = jwtService.validateTokenForRefresh(jwtToken, user); + } + + if (!valid) { throw new ServletException("Invalid token for user"); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java b/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java index 0a5dc506..c54124c6 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/JwtService.java @@ -19,4 +19,6 @@ public interface JwtService { boolean isTokenExpired(String token); boolean validateTokenAndUser(String token, UserDetails user); + + boolean validateTokenForRefresh(String token, UserDetails user); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java b/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java index 0237c9a3..935d9b5b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/impl/JwtServiceImpl.java @@ -3,10 +3,12 @@ import br.com.tasknoteapp.server.entity.User; import br.com.tasknoteapp.server.service.JwtService; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Date; @@ -28,6 +30,10 @@ class JwtServiceImpl implements JwtService { private static final long SECOND = 1000; private static final long MINUTE = SECOND * 60; private static final long EXPIRATION_TIME = MINUTE * 30; + + // How long after expiration a token may still be exchanged for a new one on the refresh + // endpoint, so sleep/wake or frozen tabs do not force a full re-login. + private static final Duration REFRESH_GRACE_PERIOD = Duration.ofHours(12); private final SecretKey key; public JwtServiceImpl(@Value("${br.com.tasknote.server.jwt-secret}") String secretKey) { @@ -98,8 +104,26 @@ public boolean isTokenExpired(String token) { public boolean validateTokenAndUser(String token, UserDetails user) { final String email = user.getUsername(); boolean basicValid = !isTokenExpired(token) && email.equals(getEmailFromToken(token)); + return basicValid && isIssuedAfterLastPasswordChange(token, user); + } + + @Override + public boolean validateTokenForRefresh(String token, UserDetails user) { + final String email = user.getUsername(); + if (!email.equals(getEmailFromToken(token))) { + return false; + } - if (basicValid && user instanceof User userEntity) { + LocalDateTime expiration = extractExpiration(token); + boolean withinGracePeriod = + expiration != null + && expiration.isAfter(LocalDateTime.now().minus(REFRESH_GRACE_PERIOD)); + + return withinGracePeriod && isIssuedAfterLastPasswordChange(token, user); + } + + private boolean isIssuedAfterLastPasswordChange(String token, UserDetails user) { + if (user instanceof User userEntity) { LocalDateTime iat = extractIssuedAt(token); if (iat != null && userEntity.getLastPasswordChange() != null) { // Token must be issued after or at the same time as last password change @@ -107,8 +131,7 @@ public boolean validateTokenAndUser(String token, UserDetails user) { return !iat.isBefore(userEntity.getLastPasswordChange()); } } - - return basicValid; + return true; } private T extractClaim(String token, Function claimsResolver) { @@ -120,6 +143,9 @@ private Optional extractAllClaims(String token) { try { return Optional.of( Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()); + } catch (ExpiredJwtException e) { + // Signature is still valid: expose the claims so callers can decide about grace periods. + return Optional.of(e.getClaims()); } catch (JwtException e) { return Optional.empty(); } diff --git a/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java b/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 00000000..8e02a9e9 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,112 @@ +package br.com.tasknoteapp.server.filter; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import br.com.tasknoteapp.server.service.JwtService; +import br.com.tasknoteapp.server.service.UserService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + private static final String REFRESH_PATH = "/rest/user-sessions/refresh"; + private static final String TOKEN = "token"; + private static final String EMAIL = "user@example.com"; + + private JwtAuthenticationFilter filter; + + @Mock private UserService userService; + @Mock private JwtService jwtService; + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + @Mock private FilterChain filterChain; + @Mock private UserDetailsService userDetailsService; + @Mock private UserDetails userDetails; + + @BeforeEach + void setUp() { + filter = new JwtAuthenticationFilter(userService, jwtService); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void stubTokenRequest(String path) { + when(request.getHeader("Authorization")).thenReturn("Bearer " + TOKEN); + when(request.getServletPath()).thenReturn(path); + when(jwtService.getEmailFromToken(TOKEN)).thenReturn(EMAIL); + when(userService.userDetailsService()).thenReturn(userDetailsService); + when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(userDetails); + lenient().when(userDetails.getUsername()).thenReturn(EMAIL); + lenient().when(userDetails.getPassword()).thenReturn("password"); + lenient().when(userDetails.getAuthorities()).thenReturn(List.of()); + } + + @Test + void doFilterInternal_validToken_authenticates() throws ServletException, IOException { + stubTokenRequest("/rest/home"); + when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(true); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).validateTokenForRefresh(any(), any()); + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_expiredTokenOnRefreshPathWithinGrace_authenticates() + throws ServletException, IOException { + stubTokenRequest(REFRESH_PATH); + when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false); + when(jwtService.validateTokenForRefresh(TOKEN, userDetails)).thenReturn(true); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_expiredTokenOnOtherPath_rejected() { + stubTokenRequest("/rest/home"); + when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false); + + assertThrows( + ServletException.class, () -> filter.doFilterInternal(request, response, filterChain)); + verify(jwtService, never()).validateTokenForRefresh(any(), any()); + } + + @Test + void doFilterInternal_expiredTokenBeyondGraceOnRefreshPath_rejected() { + stubTokenRequest(REFRESH_PATH); + when(jwtService.validateTokenAndUser(TOKEN, userDetails)).thenReturn(false); + when(jwtService.validateTokenForRefresh(TOKEN, userDetails)).thenReturn(false); + + assertThrows( + ServletException.class, () -> filter.doFilterInternal(request, response, filterChain)); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java index 70427fd3..4c318366 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/impl/JwtServiceImplTest.java @@ -188,6 +188,99 @@ void validateTokenAndUser_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange assertFalse(valid, "Token issued before password change should be invalid"); } + @Test + void validateTokenForRefresh_shouldReturnTrueForValidToken() { + User user = new User(); + user.setId(testUserId); + user.setEmail(testEmail); + user.setAdmin(false); + user.setName(testName); + String token = jwtService.generateToken(user); + + when(userDetails.getUsername()).thenReturn(testEmail); + boolean valid = jwtService.validateTokenForRefresh(token, userDetails); + + assertTrue(valid); + } + + @Test + void validateTokenForRefresh_shouldReturnTrueForRecentlyExpiredToken() { + long now = System.currentTimeMillis(); + String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000); + + when(userDetails.getUsername()).thenReturn(testEmail); + boolean valid = jwtService.validateTokenForRefresh(expiredToken, userDetails); + + assertTrue(valid, "Token expired 30 minutes ago should be refreshable within grace period"); + } + + @Test + void validateTokenForRefresh_shouldReturnFalseForTokenExpiredBeyondGracePeriod() { + long now = System.currentTimeMillis(); + long thirteenHours = 13L * 60 * 60 * 1000; + String expiredToken = + buildToken(testEmail, now - thirteenHours - 1_800_000, now - thirteenHours); + + when(userDetails.getUsername()).thenReturn(testEmail); + boolean valid = jwtService.validateTokenForRefresh(expiredToken, userDetails); + + assertFalse(valid, "Token expired beyond the grace period should not be refreshable"); + } + + @Test + void validateTokenForRefresh_shouldReturnFalseForDifferentUser() { + long now = System.currentTimeMillis(); + String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000); + + UserDetails differentUser = mock(UserDetails.class); + when(differentUser.getUsername()).thenReturn("different@example.com"); + + boolean valid = jwtService.validateTokenForRefresh(expiredToken, differentUser); + + assertFalse(valid); + } + + @Test + void validateTokenForRefresh_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange() { + long now = System.currentTimeMillis(); + String expiredToken = buildToken(testEmail, now - 3_600_000, now - 1_800_000); + + User user = new User(); + user.setId(testUserId); + user.setEmail(testEmail); + user.setAdmin(false); + user.setName(testName); + user.setLastPasswordChange(LocalDateTime.now().minusMinutes(10)); + + boolean valid = jwtService.validateTokenForRefresh(expiredToken, user); + + assertFalse(valid, "Token issued before the last password change should not be refreshable"); + } + + @Test + void validateTokenForRefresh_shouldReturnFalseForTamperedToken() { + String tamperedToken = + buildToken(testEmail, System.currentTimeMillis() - 3_600_000, + System.currentTimeMillis() - 1_800_000) + + "tampered"; + + when(userDetails.getUsername()).thenReturn(testEmail); + boolean valid = jwtService.validateTokenForRefresh(tamperedToken, userDetails); + + assertFalse(valid); + } + + private String buildToken(String subject, long issuedAtMillis, long expirationMillis) { + return Jwts.builder() + .issuer("Java-API") + .subject(subject) + .issuedAt(new Date(issuedAtMillis)) + .expiration(new Date(expirationMillis)) + .claims(Map.of()) + .signWith(getKey()) + .compact(); + } + private Claims extractClaims(String token) { return Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token).getPayload(); }