From 67f4500201efa5e074290c476010114560a1058a Mon Sep 17 00:00:00 2001 From: icrcode Date: Tue, 23 Jun 2026 20:19:21 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20adicionar=20suporte=20a=20cookies?= =?UTF-8?q?=20para=20autentica=C3=A7=C3=A3o=20e=20implementar=20rotas=20de?= =?UTF-8?q?=20login,=20cadastro=20e=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/aplicativo.ts | 4 ++ backend/src/middleware/autenticacao.ts | 7 ++-- backend/src/modulos/auth/rotas.ts | 51 ++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/backend/src/aplicativo.ts b/backend/src/aplicativo.ts index 83def3a..702351a 100644 --- a/backend/src/aplicativo.ts +++ b/backend/src/aplicativo.ts @@ -1,6 +1,7 @@ import express, { Express } from 'express'; import cors from 'cors'; import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; import registrador from './utils/registrador'; import { registro, medirRequisicoes } from './utils/metricas'; import rotasVerificacao from './routes/verificacao'; @@ -47,6 +48,9 @@ aplicativo.use( }) ); +// Cookie parser +aplicativo.use(cookieParser()); + // Analisador de corpo aplicativo.use(express.json({ limit: '10mb' })); aplicativo.use(express.urlencoded({ limit: '10mb', extended: true })); diff --git a/backend/src/middleware/autenticacao.ts b/backend/src/middleware/autenticacao.ts index c933e32..de7b02b 100644 --- a/backend/src/middleware/autenticacao.ts +++ b/backend/src/middleware/autenticacao.ts @@ -24,13 +24,14 @@ declare global { } export function autenticar(req: Request, res: Response, next: NextFunction): void { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { + const token = req.cookies?.valida_token + ?? (req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined); + + if (!token) { res.status(401).json({ erro: 'Token não fornecido' }); return; } - const token = authHeader.slice(7); try { const payload = jwt.verify(token, configuracao.jwt.segredo) as PayloadJWT; req.usuario = payload; diff --git a/backend/src/modulos/auth/rotas.ts b/backend/src/modulos/auth/rotas.ts index 77ac33a..c9bc63e 100644 --- a/backend/src/modulos/auth/rotas.ts +++ b/backend/src/modulos/auth/rotas.ts @@ -1,9 +1,10 @@ -import { Router } from 'express'; +import { Router, type CookieOptions } from 'express'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { configuracao } from '../../configuracao'; import { tratarErro } from '../../utils/erros'; import registrador from '../../utils/registrador'; +import { autenticar } from '../../middleware/autenticacao'; import { buscarPorEmailParaLogin, buscarPorEmail, @@ -14,6 +15,29 @@ import { buscarCursoPorId, listarCursoIdsDoCoordenador } from '../cursos/reposit const router = Router(); +function parseDuracaoMs(duracao: string): number { + const match = duracao.match(/^(\d+)([smhd])$/); + if (!match) return 7 * 24 * 60 * 60 * 1000; + const n = parseInt(match[1], 10); + switch (match[2]) { + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + case 'd': return n * 24 * 60 * 60 * 1000; + default: return 7 * 24 * 60 * 60 * 1000; + } +} + +function opcoesCookie(): CookieOptions { + return { + httpOnly: true, + secure: configuracao.ambienteNode === 'production', + sameSite: 'lax', + path: '/', + maxAge: parseDuracaoMs(configuracao.jwt.expiraEm), + }; +} + async function gerarToken( usuario: Omit, ): Promise { @@ -113,8 +137,8 @@ router.post('/login', async (req, res) => { instituicao: usuario.instituicao_nome, }); + res.cookie('valida_token', token, opcoesCookie()); res.json({ - token, usuario: { id: usuario.id, nome: usuario.nome, @@ -210,8 +234,8 @@ router.post('/cadastro', async (req, res) => { instituicao: curso.instituicao_nome, }); + res.cookie('valida_token', token, opcoesCookie()); res.status(201).json({ - token, usuario: { id: novoUsuario.id, nome: novoUsuario.nome, @@ -228,4 +252,25 @@ router.post('/cadastro', async (req, res) => { } }); +router.get('/me', autenticar, (req, res) => { + const u = req.usuario!; + res.json({ + usuario: { + id: u.sub, + nome: u.nome, + email: u.email, + perfil: u.perfil, + matricula: u.matricula ?? null, + curso_id: u.curso_id ?? null, + instituicao_id: u.instituicao_id ?? null, + instituicao_nome: u.instituicao_nome ?? null, + }, + }); +}); + +router.post('/logout', (_req, res) => { + res.clearCookie('valida_token', { path: '/' }); + res.json({ mensagem: 'Logout realizado' }); +}); + export default router; From 3214a18ff93144a5c8ccfd555efffda98e9a1bb4 Mon Sep 17 00:00:00 2001 From: icrcode Date: Tue, 23 Jun 2026 20:19:41 -0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20adicionar=20suporte=20a=20cookie-pa?= =?UTF-8?q?rser=20e=20atualizar=20l=C3=B3gica=20de=20autentica=C3=A7=C3=A3?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 31 +++++++++++++++++++++++++++++++ backend/package.json | 2 ++ frontend/src/services/api.ts | 12 +----------- frontend/src/services/auth.ts | 17 +++++++++++------ 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index c711a1c..546e780 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-s3": "^3.621.0", "@aws-sdk/s3-request-presigner": "^3.621.0", "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -28,6 +29,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", @@ -2220,6 +2222,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -3941,6 +3953,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2e24c02..edbfe2b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@aws-sdk/client-s3": "^3.621.0", "@aws-sdk/s3-request-presigner": "^3.621.0", "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -36,6 +37,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4b488ed..343af8e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,24 +4,14 @@ import { queryClient } from '../lib/queryClient'; const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || '', timeout: 15_000, - withCredentials: false, -}); - -api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; + withCredentials: true, }); api.interceptors.response.use( (res) => res, (error) => { if (error.response?.status === 401) { - // Limpa o cache completo antes de redirecionar para evitar dados residuais de sessão queryClient.clear(); - localStorage.removeItem('token'); localStorage.removeItem('usuario'); globalThis.location.replace('/login'); } diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 87cd988..bcc8fa7 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -1,8 +1,7 @@ import api from './api'; import type { Usuario } from '../types'; -interface LoginResponse { - token: string; +interface AuthResponse { usuario: Usuario; } @@ -15,9 +14,15 @@ export interface CadastroInput { } export const authService = { - login: (email: string, senha: string): Promise => - api.post('/api/auth/login', { email, senha }).then((r) => r.data), + login: (email: string, senha: string): Promise => + api.post('/api/auth/login', { email, senha }).then((r) => r.data), - cadastrar: (dados: CadastroInput): Promise => - api.post('/api/auth/cadastro', dados).then((r) => r.data), + cadastrar: (dados: CadastroInput): Promise => + api.post('/api/auth/cadastro', dados).then((r) => r.data), + + me: (): Promise => + api.get('/api/auth/me').then((r) => r.data), + + logout: (): Promise => + api.post('/api/auth/logout').then(() => undefined), }; From b391a32e6730f478815cb958be32b2d253fb9535 Mon Sep 17 00:00:00 2001 From: icrcode Date: Tue, 23 Jun 2026 20:19:55 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20remover=20depend=C3=AAncia=20de=20t?= =?UTF-8?q?oken=20na=20autentica=C3=A7=C3=A3o=20e=20atualizar=20l=C3=B3gic?= =?UTF-8?q?a=20de=20login=20e=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/contexts/AuthContext.tsx | 20 +++++------ frontend/src/pages/Cadastro.tsx | 4 +-- frontend/src/pages/Login.tsx | 4 +-- frontend/src/pages/Perfil.tsx | 3 +- .../src/test/contexts/AuthContext.test.tsx | 18 +++++----- .../src/test/helpers/renderWithProviders.tsx | 7 ++-- frontend/src/test/pages/Cadastro.test.tsx | 2 +- .../src/test/pages/Login.adicional.test.tsx | 4 +-- frontend/src/test/services/api.test.ts | 34 ++++--------------- frontend/src/test/services/auth.test.ts | 21 ++++++++---- 10 files changed, 50 insertions(+), 67 deletions(-) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index cc21b23..f223acd 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,14 +1,14 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; import type { Usuario } from '../types'; import { queryClient } from '../lib/queryClient'; +import api from '../services/api'; interface AuthState { - token: string | null; usuario: Usuario | null; } interface AuthContextValue extends AuthState { - login: (token: string, usuario: Usuario) => void; + login: (usuario: Usuario) => void; logout: () => void; isAuthenticated: boolean; transitioning: boolean; @@ -17,37 +17,33 @@ interface AuthContextValue extends AuthState { const AuthContext = createContext(null); function carregarEstadoInicial(): AuthState { - const token = localStorage.getItem('token'); const raw = localStorage.getItem('usuario'); const usuario = raw ? (JSON.parse(raw) as Usuario) : null; - return { token, usuario }; + return { usuario }; } export function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(carregarEstadoInicial); const [transitioning, setTransitioning] = useState(false); - const login = useCallback((token: string, usuario: Usuario) => { - // Limpa cache da sessão anterior antes de renderizar qualquer dado novo + const login = useCallback((usuario: Usuario) => { queryClient.clear(); setTransitioning(true); - localStorage.setItem('token', token); localStorage.setItem('usuario', JSON.stringify(usuario)); - setState({ token, usuario }); - // Aguarda dois frames: um para montar a tela de carregamento, outro para liberar + setState({ usuario }); requestAnimationFrame(() => requestAnimationFrame(() => setTransitioning(false))); }, []); const logout = useCallback(() => { + api.post('/api/auth/logout').catch(() => {}); queryClient.clear(); - localStorage.removeItem('token'); localStorage.removeItem('usuario'); - setState({ token: null, usuario: null }); + setState({ usuario: null }); }, []); return ( {children} diff --git a/frontend/src/pages/Cadastro.tsx b/frontend/src/pages/Cadastro.tsx index f55ded5..a5239fe 100644 --- a/frontend/src/pages/Cadastro.tsx +++ b/frontend/src/pages/Cadastro.tsx @@ -216,14 +216,14 @@ export function Cadastro() { setErro(''); setLoading(true); try { - const { token, usuario } = await authService.cadastrar({ + const { usuario } = await authService.cadastrar({ nome: nome.trim(), email: email.trim().toLowerCase(), senha, matricula: matricula.trim(), curso_id: cursoId, }); - login(token, usuario); + login(usuario); navigate('/dashboard'); } catch (err: unknown) { setErro(mensagemErroSegura(err, 'Falha ao criar conta. Tente novamente.')); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1982935..0164267 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -41,8 +41,8 @@ export function Login() { setErro(''); setLoading(true); try { - const { token, usuario } = await authService.login(email.trim().toLowerCase(), senha); - login(token, usuario); + const { usuario } = await authService.login(email.trim().toLowerCase(), senha); + login(usuario); navigate('/dashboard'); } catch (err: unknown) { setErro(mensagemErroSegura(err, 'Falha ao conectar com o servidor')); diff --git a/frontend/src/pages/Perfil.tsx b/frontend/src/pages/Perfil.tsx index 5da0fba..cd65752 100644 --- a/frontend/src/pages/Perfil.tsx +++ b/frontend/src/pages/Perfil.tsx @@ -37,7 +37,6 @@ export function Perfil() { const { usuario, login } = useAuth(); const { addToast } = useToast(); const queryClient = useQueryClient(); - const token = localStorage.getItem('token') ?? ''; const [modo, setModo] = useState('ver'); @@ -87,7 +86,7 @@ export function Perfil() { endereco: temCpfEndereco ? (endereco.trim() || null) : undefined, }), onSuccess: (atualizado) => { - login(token, atualizado); + login(atualizado); queryClient.setQueryData(['perfil'], atualizado); addToast('Dados atualizados com sucesso!', 'success'); setModo('ver'); diff --git a/frontend/src/test/contexts/AuthContext.test.tsx b/frontend/src/test/contexts/AuthContext.test.tsx index c5a077f..bcb0cfb 100644 --- a/frontend/src/test/contexts/AuthContext.test.tsx +++ b/frontend/src/test/contexts/AuthContext.test.tsx @@ -1,8 +1,12 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { AuthProvider, useAuth } from '../../contexts/AuthContext'; import type { Usuario } from '../../types'; +vi.mock('../../services/api', () => ({ + default: { post: vi.fn().mockResolvedValue({}) }, +})); + const USUARIO_MOCK: Usuario = { id: 'u-1', nome: 'João Silva', @@ -23,7 +27,7 @@ function Probe() {
{String(auth.isAuthenticated)} {auth.usuario?.nome ?? 'sem-usuario'} - +
); @@ -34,13 +38,12 @@ beforeEach(() => { }); describe('AuthContext', () => { - it('inicia não autenticado quando não há token no localStorage', () => { + it('inicia não autenticado quando não há usuario no localStorage', () => { render(); expect(screen.getByTestId('autenticado').textContent).toBe('false'); }); - it('inicia autenticado quando há token no localStorage', () => { - localStorage.setItem('token', 'tok-existente'); + it('inicia autenticado quando há usuario no localStorage', () => { localStorage.setItem('usuario', JSON.stringify(USUARIO_MOCK)); render(); expect(screen.getByTestId('autenticado').textContent).toBe('true'); @@ -53,18 +56,17 @@ describe('AuthContext', () => { }); expect(screen.getByTestId('autenticado').textContent).toBe('true'); expect(screen.getByTestId('nome').textContent).toBe('João Silva'); - expect(localStorage.getItem('token')).toBe('tok-123'); + expect(localStorage.getItem('usuario')).toBeTruthy(); }); it('faz logout corretamente', async () => { - localStorage.setItem('token', 'tok-123'); localStorage.setItem('usuario', JSON.stringify(USUARIO_MOCK)); render(); await act(async () => { screen.getByText('logout').click(); }); expect(screen.getByTestId('autenticado').textContent).toBe('false'); - expect(localStorage.getItem('token')).toBeNull(); + expect(localStorage.getItem('usuario')).toBeNull(); }); it('lança erro quando useAuth é usado fora do AuthProvider', () => { diff --git a/frontend/src/test/helpers/renderWithProviders.tsx b/frontend/src/test/helpers/renderWithProviders.tsx index 0113f72..a14627e 100644 --- a/frontend/src/test/helpers/renderWithProviders.tsx +++ b/frontend/src/test/helpers/renderWithProviders.tsx @@ -23,11 +23,10 @@ interface Options { } export function renderWithProviders(ui: ReactNode, options: Options = {}) { - const { route = '/', token, usuario } = options; + const { route = '/', usuario } = options; - if (token) { - localStorage.setItem('token', token); - if (usuario) localStorage.setItem('usuario', JSON.stringify(usuario)); + if (usuario) { + localStorage.setItem('usuario', JSON.stringify(usuario)); } const queryClient = createTestQueryClient(); diff --git a/frontend/src/test/pages/Cadastro.test.tsx b/frontend/src/test/pages/Cadastro.test.tsx index 31b0304..f23bbd5 100644 --- a/frontend/src/test/pages/Cadastro.test.tsx +++ b/frontend/src/test/pages/Cadastro.test.tsx @@ -377,7 +377,7 @@ describe('Cadastro — submit', () => { } it('chama authService.cadastrar com o curso correspondente ao turno escolhido', async () => { - mockAuthService.cadastrar.mockResolvedValue({ token: 'tok', usuario: { id: 'u-1', nome: 'Nome Válido', email: 'joao@aluno.ut.edu.br', perfil: 'estudante' } }); + mockAuthService.cadastrar.mockResolvedValue({ usuario: { id: 'u-1', nome: 'Nome Válido', email: 'joao@aluno.ut.edu.br', perfil: 'estudante' } }); renderWithProviders(); await preencherFluxoCompleto(); fireEvent.click(screen.getByText('Criar conta')); diff --git a/frontend/src/test/pages/Login.adicional.test.tsx b/frontend/src/test/pages/Login.adicional.test.tsx index b690c9c..b7e25e1 100644 --- a/frontend/src/test/pages/Login.adicional.test.tsx +++ b/frontend/src/test/pages/Login.adicional.test.tsx @@ -114,7 +114,7 @@ describe('Login — etapa de senha', () => { describe('Login — submit com sucesso e erro', () => { it('chama authService.login e navega para dashboard em caso de sucesso', async () => { - mockAuthService.login.mockResolvedValue({ token: 'tok-123', usuario: { id: 'u-1', nome: 'João', email: 'joao@test.com', perfil: 'estudante' } }); + mockAuthService.login.mockResolvedValue({ usuario: { id: 'u-1', nome: 'João', email: 'joao@test.com', perfil: 'estudante' } }); renderWithProviders(); avancarParaSenha('joao@test.com'); fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'senha123' } }); @@ -139,7 +139,7 @@ describe('Login — submit com sucesso e erro', () => { }); it('normaliza o e-mail para lowercase antes de enviar', async () => { - mockAuthService.login.mockResolvedValue({ token: 'tok', usuario: { id: 'u-1', nome: 'João', email: 'joao@test.com', perfil: 'estudante' } }); + mockAuthService.login.mockResolvedValue({ usuario: { id: 'u-1', nome: 'João', email: 'joao@test.com', perfil: 'estudante' } }); renderWithProviders(); avancarParaSenha(' JOAO@TEST.COM '); fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'senha123' } }); diff --git a/frontend/src/test/services/api.test.ts b/frontend/src/test/services/api.test.ts index 307d849..b4dfa66 100644 --- a/frontend/src/test/services/api.test.ts +++ b/frontend/src/test/services/api.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// Testa os interceptors do axios diretamente sem mockar o módulo api -// O queryClient precisa ser mockado para evitar imports circulares vi.mock('../../lib/queryClient', () => ({ queryClient: { clear: vi.fn() }, })); @@ -16,37 +14,21 @@ describe('api interceptors', () => { vi.unstubAllGlobals(); }); - it('adiciona header Authorization quando token existe no localStorage', async () => { - localStorage.setItem('token', 'meu-token-123'); + it('configura withCredentials como true', async () => { const { default: api } = await import('../../services/api'); - - // Pega o handler do request interceptor e executa diretamente - const handlers = (api.interceptors.request as unknown as { - handlers: Array<{ fulfilled: (c: unknown) => unknown }>; - }).handlers; - const handler = handlers[handlers.length - 1]?.fulfilled; - expect(handler).toBeDefined(); - - const fakeConfig = { headers: {} as Record }; - const result = handler(fakeConfig) as typeof fakeConfig; - expect(result.headers.Authorization).toBe('Bearer meu-token-123'); + expect(api.defaults.withCredentials).toBe(true); }); - it('não adiciona header Authorization quando token não existe', async () => { + it('não possui request interceptor de Authorization', async () => { const { default: api } = await import('../../services/api'); - const handlers = (api.interceptors.request as unknown as { - handlers: Array<{ fulfilled: (c: unknown) => unknown }>; + handlers: Array<{ fulfilled: (c: unknown) => unknown } | null>; }).handlers; - const handler = handlers[handlers.length - 1]?.fulfilled; - - const fakeConfig = { headers: {} as Record }; - const result = handler(fakeConfig) as typeof fakeConfig; - expect(result.headers.Authorization).toBeUndefined(); + const activeHandlers = handlers.filter(Boolean); + expect(activeHandlers).toHaveLength(0); }); - it('limpa token e redireciona ao receber 401', async () => { - localStorage.setItem('token', 'tok'); + it('limpa usuario e redireciona ao receber 401', async () => { localStorage.setItem('usuario', '{}'); const { default: api } = await import('../../services/api'); const { queryClient } = await import('../../lib/queryClient'); @@ -59,7 +41,6 @@ describe('api interceptors', () => { await expect(errorHandler({ response: { status: 401 } })).rejects.toBeTruthy(); expect(queryClient.clear).toHaveBeenCalled(); - expect(localStorage.getItem('token')).toBeNull(); expect(localStorage.getItem('usuario')).toBeNull(); }); @@ -85,7 +66,6 @@ describe('api interceptors', () => { }).handlers; const errorHandler = responseHandlers[responseHandlers.length - 1]?.rejected; - // Erro sem response (timeout, CORS, etc.) await expect(errorHandler({ message: 'Network Error' })).rejects.toMatchObject({ message: 'Network Error', }); diff --git a/frontend/src/test/services/auth.test.ts b/frontend/src/test/services/auth.test.ts index bfbe9e8..e2149ec 100644 --- a/frontend/src/test/services/auth.test.ts +++ b/frontend/src/test/services/auth.test.ts @@ -5,7 +5,7 @@ vi.mock('../../services/api'); import api from '../../services/api'; -const mockApi = api as unknown as { post: ReturnType }; +const mockApi = api as unknown as { post: ReturnType; get: ReturnType }; const USUARIO_MOCK = { id: 'u-1', nome: 'João', email: 'joao@test.com', perfil: 'estudante' as const, @@ -16,21 +16,20 @@ const USUARIO_MOCK = { beforeEach(() => vi.clearAllMocks()); describe('authService.login', () => { - it('chama POST /api/auth/login e retorna token e usuario', async () => { - const resposta = { token: 'tok-123', usuario: USUARIO_MOCK }; + it('chama POST /api/auth/login e retorna usuario', async () => { + const resposta = { usuario: USUARIO_MOCK }; mockApi.post.mockResolvedValueOnce({ data: resposta }); const resultado = await authService.login('joao@test.com', 'senha123'); expect(mockApi.post).toHaveBeenCalledWith('/api/auth/login', { email: 'joao@test.com', senha: 'senha123' }); - expect(resultado.token).toBe('tok-123'); expect(resultado.usuario.email).toBe('joao@test.com'); }); }); describe('authService.cadastrar', () => { - it('chama POST /api/auth/cadastro e retorna token e usuario', async () => { - const resposta = { token: 'tok-456', usuario: USUARIO_MOCK }; + it('chama POST /api/auth/cadastro e retorna usuario', async () => { + const resposta = { usuario: USUARIO_MOCK }; mockApi.post.mockResolvedValueOnce({ data: resposta }); const resultado = await authService.cadastrar({ @@ -39,6 +38,14 @@ describe('authService.cadastrar', () => { }); expect(mockApi.post).toHaveBeenCalledWith('/api/auth/cadastro', expect.objectContaining({ nome: 'João' })); - expect(resultado.token).toBe('tok-456'); + expect(resultado.usuario.email).toBe('joao@test.com'); + }); +}); + +describe('authService.logout', () => { + it('chama POST /api/auth/logout', async () => { + mockApi.post.mockResolvedValueOnce({ data: {} }); + await authService.logout(); + expect(mockApi.post).toHaveBeenCalledWith('/api/auth/logout'); }); }); From ba434c00f2c2728162368ad6246b0f9eeadc0484 Mon Sep 17 00:00:00 2001 From: icrcode Date: Tue, 23 Jun 2026 20:26:46 -0300 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20atualizar=20testes=20de=20autentica?= =?UTF-8?q?=C3=A7=C3=A3o=20para=20verificar=20cookies=20httpOnly=20em=20ve?= =?UTF-8?q?z=20de=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/testes/rotas/auth.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/testes/rotas/auth.test.ts b/backend/testes/rotas/auth.test.ts index 32f3e18..c2377ba 100644 --- a/backend/testes/rotas/auth.test.ts +++ b/backend/testes/rotas/auth.test.ts @@ -135,12 +135,13 @@ describe('POST /login', () => { expect(res.body.erro).toContain('Domínio'); }); - it('retorna 200 com token quando login é bem-sucedido (sem restrição de domínio)', async () => { + it('retorna 200 com cookie httpOnly quando login é bem-sucedido (sem restrição de domínio)', async () => { mockUsuarios.buscarPorEmailParaLogin.mockResolvedValueOnce(USUARIO_LOGIN_MOCK); mockBcryptCompare.mockResolvedValueOnce(true); const res = await request(app).post('/login').send(DADOS_LOGIN); expect(res.status).toBe(200); - expect(res.body.token).toBeDefined(); + const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []); + expect(cookies.some((c) => c.startsWith('valida_token=') && c.includes('HttpOnly'))).toBe(true); expect(res.body.usuario.email).toBe('joao@uni.edu'); expect(res.body.usuario.perfil).toBe('estudante'); }); @@ -153,7 +154,8 @@ describe('POST /login', () => { mockBcryptCompare.mockResolvedValueOnce(true); const res = await request(app).post('/login').send(DADOS_LOGIN); expect(res.status).toBe(200); - expect(res.body.token).toBeDefined(); + const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []); + expect(cookies.some((c) => c.startsWith('valida_token='))).toBe(true); }); it('retorna 200 quando lista de domínios está vazia (sem restrição)', async () => { @@ -234,14 +236,15 @@ describe('POST /cadastro', () => { expect(res.status).toBe(403); }); - it('retorna 201 com token quando cadastro é bem-sucedido', async () => { + it('retorna 201 com cookie httpOnly quando cadastro é bem-sucedido', async () => { mockUsuarios.buscarPorEmail.mockResolvedValueOnce(null); mockCursos.buscarCursoPorId.mockResolvedValueOnce(CURSO_MOCK); mockBcryptHash.mockResolvedValueOnce('$2b$10$hashed'); mockUsuarios.criarUsuario.mockResolvedValueOnce(USUARIO_CRIADO); const res = await request(app).post('/cadastro').send(DADOS_CADASTRO); expect(res.status).toBe(201); - expect(res.body.token).toBeDefined(); + const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []); + expect(cookies.some((c) => c.startsWith('valida_token=') && c.includes('HttpOnly'))).toBe(true); expect(res.body.usuario.perfil).toBe('estudante'); expect(res.body.usuario.email).toBe('maria@uni.edu'); });