UDT-002: Logout + Refresh Token con rotación y chain revocation #3
@@ -12,34 +12,59 @@ interface SetAuthPayload {
|
|||||||
user: AuthUser
|
user: AuthUser
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
expiresIn: number
|
expiresIn: number // seconds from backend
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
expiresAt: number | null // ms epoch UTC
|
||||||
setAuth: (payload: SetAuthPayload) => void
|
setAuth: (payload: SetAuthPayload) => void
|
||||||
logout: () => void
|
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
|
||||||
|
clearAuth: () => void
|
||||||
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
|
||||||
setAuth: (payload: SetAuthPayload) => {
|
setAuth: (payload) =>
|
||||||
set({
|
set({
|
||||||
user: payload.user,
|
user: payload.user,
|
||||||
accessToken: payload.accessToken,
|
accessToken: payload.accessToken,
|
||||||
})
|
refreshToken: payload.refreshToken,
|
||||||
},
|
expiresAt: Date.now() + payload.expiresIn * 1000,
|
||||||
|
}),
|
||||||
|
|
||||||
logout: () => {
|
updateAccess: (accessToken, refreshToken, expiresAt) =>
|
||||||
|
set({ accessToken, refreshToken, expiresAt }),
|
||||||
|
|
||||||
|
clearAuth: () =>
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: 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) => ({
|
partialize: (state) => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
accessToken: state.accessToken,
|
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'
|
import { useAuthStore } from '../../stores/authStore'
|
||||||
|
|
||||||
describe('authStore', () => {
|
describe('authStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset store state before each test
|
// Reset store state before each test
|
||||||
useAuthStore.getState().logout()
|
useAuthStore.setState({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
})
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe('initial state', () => {
|
describe('initial state', () => {
|
||||||
it('starts with null user and null accessToken', () => {
|
it('starts with null user and null accessToken', () => {
|
||||||
const state = useAuthStore.getState()
|
const state = useAuthStore.getState()
|
||||||
@@ -48,11 +57,123 @@ describe('authStore', () => {
|
|||||||
expect(parsed.state.accessToken).toBe(payload.accessToken)
|
expect(parsed.state.accessToken).toBe(payload.accessToken)
|
||||||
expect(parsed.state.user.username).toBe('admin')
|
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', () => {
|
describe('clearAuth', () => {
|
||||||
it('clears user and accessToken from state', () => {
|
it('clearAuth_removesAllFields', () => {
|
||||||
// Setup: set auth first
|
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({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||||
accessToken: 'some-token',
|
accessToken: 'some-token',
|
||||||
@@ -60,14 +181,14 @@ describe('authStore', () => {
|
|||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
})
|
})
|
||||||
|
|
||||||
useAuthStore.getState().logout()
|
await useAuthStore.getState().logout()
|
||||||
|
|
||||||
const state = useAuthStore.getState()
|
const state = useAuthStore.getState()
|
||||||
expect(state.user).toBeNull()
|
expect(state.user).toBeNull()
|
||||||
expect(state.accessToken).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({
|
useAuthStore.getState().setAuth({
|
||||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||||
accessToken: 'some-token',
|
accessToken: 'some-token',
|
||||||
@@ -75,10 +196,9 @@ describe('authStore', () => {
|
|||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
})
|
})
|
||||||
|
|
||||||
useAuthStore.getState().logout()
|
await useAuthStore.getState().logout()
|
||||||
|
|
||||||
const stored = localStorage.getItem('auth-storage')
|
const stored = localStorage.getItem('auth-storage')
|
||||||
// After logout the persisted state should have null user/token
|
|
||||||
if (stored !== null) {
|
if (stored !== null) {
|
||||||
const parsed = JSON.parse(stored)
|
const parsed = JSON.parse(stored)
|
||||||
expect(parsed.state.user).toBeNull()
|
expect(parsed.state.user).toBeNull()
|
||||||
|
|||||||
Reference in New Issue
Block a user