UDT-002: Logout + Refresh Token con rotación y chain revocation #3

Merged
dmolinari merged 36 commits from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
2 changed files with 107 additions and 1 deletions
Showing only changes of commit d40b7247fc - Show all commits

View File

@@ -12,6 +12,22 @@ export interface LoginResponseDto {
} }
} }
export interface RefreshRequest {
accessToken: string
refreshToken: string
}
export interface RefreshResponse {
accessToken: string
refreshToken: string
expiresIn: number
}
export interface LogoutResponse {
success: boolean
mensaje: string
}
export async function login(username: string, password: string): Promise<LoginResponseDto> { export async function login(username: string, password: string): Promise<LoginResponseDto> {
const response = await axiosClient.post<LoginResponseDto>('/api/v1/auth/login', { const response = await axiosClient.post<LoginResponseDto>('/api/v1/auth/login', {
username, username,
@@ -19,3 +35,13 @@ export async function login(username: string, password: string): Promise<LoginRe
}) })
return response.data return response.data
} }
export async function refresh(payload: RefreshRequest): Promise<RefreshResponse> {
const response = await axiosClient.post<RefreshResponse>('/api/v1/auth/refresh', payload)
return response.data
}
export async function logout(): Promise<LogoutResponse> {
const response = await axiosClient.post<LogoutResponse>('/api/v1/auth/logout', {})
return response.data
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node' import { setupServer } from 'msw/node'
import { login } from '../../../features/auth/api/authApi' import { login, refresh, logout } from '../../../features/auth/api/authApi'
const API_URL = 'http://localhost:5000' const API_URL = 'http://localhost:5000'
@@ -17,6 +17,17 @@ const mockLoginResponse = {
}, },
} }
const mockRefreshResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.new-payload.new-sig',
refreshToken: 'new-opaque-refresh-token-xyz',
expiresIn: 3600,
}
const mockLogoutResponse = {
success: true,
mensaje: 'Sesion cerrada correctamente',
}
const server = setupServer( const server = setupServer(
http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => { http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => {
const body = await request.json() as { username: string; password: string } const body = await request.json() as { username: string; password: string }
@@ -25,6 +36,18 @@ const server = setupServer(
} }
return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 }) return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 })
}), }),
http.post(`${API_URL}/api/v1/auth/refresh`, async ({ request }) => {
const body = await request.json() as { accessToken: string; refreshToken: string }
if (body.accessToken && body.refreshToken) {
return HttpResponse.json(mockRefreshResponse, { status: 200 })
}
return HttpResponse.json({ error: 'invalid_token' }, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/logout`, async () => {
return HttpResponse.json(mockLogoutResponse, { status: 200 })
}),
) )
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
@@ -45,3 +68,60 @@ describe('login()', () => {
await expect(login('admin', 'wrongpassword')).rejects.toThrow() await expect(login('admin', 'wrongpassword')).rejects.toThrow()
}) })
}) })
describe('refresh()', () => {
it('refresh_callsCorrectEndpoint_withPayload', async () => {
let capturedBody: unknown = null
server.use(
http.post(`${API_URL}/api/v1/auth/refresh`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockRefreshResponse, { status: 200 })
}),
)
const payload = {
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
}
const result = await refresh(payload)
expect(result.accessToken).toBe(mockRefreshResponse.accessToken)
expect(result.refreshToken).toBe(mockRefreshResponse.refreshToken)
expect(result.expiresIn).toBe(3600)
expect(capturedBody).toEqual(payload)
})
it('throws on invalid refresh token (401)', async () => {
server.use(
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
return HttpResponse.json({ error: 'invalid_token' }, { status: 401 })
}),
)
await expect(
refresh({ accessToken: 'bad-access', refreshToken: 'bad-refresh' }),
).rejects.toThrow()
})
})
describe('logout()', () => {
it('logout_callsCorrectEndpoint', async () => {
let requestUrl: string | null = null
let requestMethod: string | null = null
server.use(
http.post(`${API_URL}/api/v1/auth/logout`, ({ request }) => {
requestUrl = request.url
requestMethod = request.method
return HttpResponse.json(mockLogoutResponse, { status: 200 })
}),
)
const result = await logout()
expect(result.success).toBe(true)
expect(result.mensaje).toBe('Sesion cerrada correctamente')
expect(requestUrl).toContain('/api/v1/auth/logout')
expect(requestMethod).toBe('POST')
})
})