UDT-002: Logout + Refresh Token con rotación y chain revocation #3
@@ -12,34 +12,59 @@ interface SetAuthPayload {
|
||||
user: AuthUser
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
expiresIn: number // seconds from backend
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
expiresAt: number | null // ms epoch UTC
|
||||
setAuth: (payload: SetAuthPayload) => void
|
||||
logout: () => void
|
||||
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
|
||||
clearAuth: () => void
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
|
||||
setAuth: (payload: SetAuthPayload) => {
|
||||
setAuth: (payload) =>
|
||||
set({
|
||||
user: payload.user,
|
||||
accessToken: payload.accessToken,
|
||||
})
|
||||
},
|
||||
refreshToken: payload.refreshToken,
|
||||
expiresAt: Date.now() + payload.expiresIn * 1000,
|
||||
}),
|
||||
|
||||
logout: () => {
|
||||
updateAccess: (accessToken, refreshToken, expiresAt) =>
|
||||
set({ accessToken, refreshToken, expiresAt }),
|
||||
|
||||
clearAuth: () =>
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
})
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
}),
|
||||
|
||||
logout: async () => {
|
||||
const { accessToken, clearAuth } = get()
|
||||
if (accessToken) {
|
||||
try {
|
||||
// Lazy import to break circular dependency with axiosClient
|
||||
const { logout: apiLogout } = await import('@/features/auth/api/authApi')
|
||||
await apiLogout()
|
||||
} catch {
|
||||
// Ignore API errors — local logout is always safe
|
||||
}
|
||||
}
|
||||
clearAuth()
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -47,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresAt: state.expiresAt,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { useAuthStore } from '../../stores/authStore'
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useAuthStore.getState().logout()
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
})
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with null user and null accessToken', () => {
|
||||
const state = useAuthStore.getState()
|
||||
@@ -48,11 +57,123 @@ describe('authStore', () => {
|
||||
expect(parsed.state.accessToken).toBe(payload.accessToken)
|
||||
expect(parsed.state.user.username).toBe('admin')
|
||||
})
|
||||
|
||||
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
|
||||
const before = Date.now()
|
||||
const payload = {
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'access-token-abc',
|
||||
refreshToken: 'opaque-refresh-xyz',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
|
||||
useAuthStore.getState().setAuth(payload)
|
||||
const after = Date.now()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.refreshToken).toBe('opaque-refresh-xyz')
|
||||
expect(state.expiresAt).not.toBeNull()
|
||||
// expiresAt should be ~Date.now() + 3600*1000
|
||||
expect(state.expiresAt!).toBeGreaterThanOrEqual(before + 3600 * 1000)
|
||||
expect(state.expiresAt!).toBeLessThanOrEqual(after + 3600 * 1000)
|
||||
|
||||
// Should also be persisted in localStorage
|
||||
const stored = localStorage.getItem('auth-storage')
|
||||
const parsed = JSON.parse(stored!)
|
||||
expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz')
|
||||
expect(parsed.state.expiresAt).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears user and accessToken from state', () => {
|
||||
// Setup: set auth first
|
||||
describe('clearAuth', () => {
|
||||
it('clearAuth_removesAllFields', () => {
|
||||
useAuthStore.getState().setAuth({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
useAuthStore.getState().clearAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
expect(state.refreshToken).toBeNull()
|
||||
expect(state.expiresAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateAccess', () => {
|
||||
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
|
||||
const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }
|
||||
useAuthStore.getState().setAuth({
|
||||
user: originalUser,
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'old-refresh',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
const newExpiresAt = Date.now() + 7200 * 1000
|
||||
useAuthStore.getState().updateAccess('new-access', 'new-refresh', newExpiresAt)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.accessToken).toBe('new-access')
|
||||
expect(state.refreshToken).toBe('new-refresh')
|
||||
expect(state.expiresAt).toBe(newExpiresAt)
|
||||
// user should be preserved
|
||||
expect(state.user).toEqual(originalUser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout (async)', () => {
|
||||
it('logout_callsApi_thenClearsAuth', async () => {
|
||||
// Set up auth state with a token so logout() will try to call the API
|
||||
useAuthStore.getState().setAuth({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
// logout() does a lazy import of authApi and calls logout()
|
||||
// The API call may succeed or fail (we don't control the real import here),
|
||||
// but clearAuth() is ALWAYS called after regardless.
|
||||
await useAuthStore.getState().logout()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
expect(state.refreshToken).toBeNull()
|
||||
expect(state.expiresAt).toBeNull()
|
||||
})
|
||||
|
||||
it('logout_apiFails_stillClearsAuth', async () => {
|
||||
useAuthStore.getState().setAuth({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
// Should NOT throw even if the dynamic import fails
|
||||
// (We test this by verifying clearAuth is always called)
|
||||
let threw = false
|
||||
try {
|
||||
await useAuthStore.getState().logout()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
expect(threw).toBe(false)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy logout compatibility', () => {
|
||||
it('clears user and accessToken from state', async () => {
|
||||
useAuthStore.getState().setAuth({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'some-token',
|
||||
@@ -60,14 +181,14 @@ describe('authStore', () => {
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
useAuthStore.getState().logout()
|
||||
await useAuthStore.getState().logout()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('removes auth-storage from localStorage on logout', () => {
|
||||
it('removes auth-storage from localStorage on logout', async () => {
|
||||
useAuthStore.getState().setAuth({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken: 'some-token',
|
||||
@@ -75,10 +196,9 @@ describe('authStore', () => {
|
||||
expiresIn: 3600,
|
||||
})
|
||||
|
||||
useAuthStore.getState().logout()
|
||||
await useAuthStore.getState().logout()
|
||||
|
||||
const stored = localStorage.getItem('auth-storage')
|
||||
// After logout the persisted state should have null user/token
|
||||
if (stored !== null) {
|
||||
const parsed = JSON.parse(stored)
|
||||
expect(parsed.state.user).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user