devsquad 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ # NestJS — Contratos de API (Formato padrão)
2
+
3
+ ## Formato padrão de resposta
4
+
5
+ Todas as respostas da API seguem este contrato. Isso garante que o frontend possa tratar respostas de forma consistente.
6
+
7
+ ### Sucesso
8
+
9
+ ```typescript
10
+ // Resposta de sucesso — item único
11
+ {
12
+ "data": { "id": "uuid", "name": "João", "email": "joao@email.com" },
13
+ "meta": null
14
+ }
15
+
16
+ // Resposta de sucesso — lista paginada
17
+ {
18
+ "data": [{ "id": "uuid", "name": "João" }],
19
+ "meta": {
20
+ "total": 100,
21
+ "page": 1,
22
+ "perPage": 20,
23
+ "totalPages": 5
24
+ }
25
+ }
26
+
27
+ // Resposta de criação (201)
28
+ {
29
+ "data": { "id": "uuid-novo" }
30
+ }
31
+
32
+ // Resposta sem conteúdo (204) — sem body
33
+ ```
34
+
35
+ ### Erro
36
+
37
+ ```typescript
38
+ // Erro de validação (400)
39
+ {
40
+ "statusCode": 400,
41
+ "error": "Bad Request",
42
+ "message": ["email must be an email", "password is too weak"]
43
+ }
44
+
45
+ // Erro de autenticação (401)
46
+ {
47
+ "statusCode": 401,
48
+ "error": "Unauthorized",
49
+ "message": "Credenciais inválidas"
50
+ }
51
+
52
+ // Erro de permissão (403)
53
+ {
54
+ "statusCode": 403,
55
+ "error": "Forbidden",
56
+ "message": "Sem permissão para esta ação"
57
+ }
58
+
59
+ // Não encontrado (404)
60
+ {
61
+ "statusCode": 404,
62
+ "error": "Not Found",
63
+ "message": "Usuário não encontrado"
64
+ }
65
+
66
+ // Conflito (409)
67
+ {
68
+ "statusCode": 409,
69
+ "error": "Conflict",
70
+ "message": "E-mail já cadastrado"
71
+ }
72
+ ```
73
+
74
+ ## Implementação — Response Interceptor
75
+
76
+ ```typescript
77
+ // src/common/interceptors/response.interceptor.ts
78
+ import {
79
+ Injectable,
80
+ NestInterceptor,
81
+ ExecutionContext,
82
+ CallHandler,
83
+ } from '@nestjs/common';
84
+ import { Observable } from 'rxjs';
85
+ import { map } from 'rxjs/operators';
86
+
87
+ export interface ApiResponse<T> {
88
+ data: T;
89
+ meta: PaginationMeta | null;
90
+ }
91
+
92
+ export interface PaginationMeta {
93
+ total: number;
94
+ page: number;
95
+ perPage: number;
96
+ totalPages: number;
97
+ }
98
+
99
+ @Injectable()
100
+ export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
101
+ intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
102
+ return next.handle().pipe(
103
+ map((value) => {
104
+ // Se já tem formato { data, meta } — retorna como está
105
+ if (value && 'data' in value) return value;
106
+ // Caso contrário, envolve em { data, meta: null }
107
+ return { data: value, meta: null };
108
+ }),
109
+ );
110
+ }
111
+ }
112
+ ```
113
+
114
+ ```typescript
115
+ // src/main.ts — registrar globalmente
116
+ app.useGlobalInterceptors(new ResponseInterceptor());
117
+ ```
118
+
119
+ ## Implementação — Paginação
120
+
121
+ ```typescript
122
+ // src/common/dto/pagination.dto.ts
123
+ import { Type } from 'class-transformer';
124
+ import { IsInt, IsOptional, Max, Min } from 'class-validator';
125
+
126
+ export class PaginationDto {
127
+ @IsOptional()
128
+ @Type(() => Number)
129
+ @IsInt()
130
+ @Min(1)
131
+ page: number = 1;
132
+
133
+ @IsOptional()
134
+ @Type(() => Number)
135
+ @IsInt()
136
+ @Min(1)
137
+ @Max(100)
138
+ perPage: number = 20;
139
+
140
+ get skip(): number {
141
+ return (this.page - 1) * this.perPage;
142
+ }
143
+ }
144
+ ```
145
+
146
+ ```typescript
147
+ // src/common/helpers/paginate.helper.ts
148
+ import { PaginationMeta } from '../interceptors/response.interceptor';
149
+
150
+ export function paginate<T>(
151
+ data: T[],
152
+ total: number,
153
+ page: number,
154
+ perPage: number,
155
+ ): { data: T[]; meta: PaginationMeta } {
156
+ return {
157
+ data,
158
+ meta: {
159
+ total,
160
+ page,
161
+ perPage,
162
+ totalPages: Math.ceil(total / perPage),
163
+ },
164
+ };
165
+ }
166
+ ```
167
+
168
+ ## Uso no Controller
169
+
170
+ ```typescript
171
+ // Resposta paginada
172
+ @Get()
173
+ async findAll(@Query() pagination: PaginationDto) {
174
+ const [users, total] = await this.usersService.findAll(pagination);
175
+ return paginate(users, total, pagination.page, pagination.perPage);
176
+ }
177
+
178
+ // Resposta simples (envolvida automaticamente pelo interceptor)
179
+ @Get(':id')
180
+ async findOne(@Param('id', ParseUUIDPipe) id: string) {
181
+ return this.usersService.findOne(id);
182
+ }
183
+
184
+ // Criação
185
+ @Post()
186
+ @HttpCode(201)
187
+ async create(@Body() dto: CreateUserDto) {
188
+ return this.usersService.create(dto);
189
+ }
190
+
191
+ // Sem conteúdo
192
+ @Delete(':id')
193
+ @HttpCode(204)
194
+ async remove(@Param('id', ParseUUIDPipe) id: string) {
195
+ await this.usersService.remove(id);
196
+ }
197
+ ```
198
+
199
+ ## Nunca retornar senha ou dados sensíveis
200
+
201
+ ```typescript
202
+ // ❌ ERRADO — senha no response
203
+ return user;
204
+
205
+ // ✅ CORRETO — excluir campos sensíveis
206
+ const { password, hashedRefreshToken, ...result } = user;
207
+ return result;
208
+
209
+ // ✅ ALTERNATIVA — usar @Exclude no Prisma result
210
+ // Configurar class-transformer no main.ts:
211
+ app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
212
+ ```
213
+
214
+ ## Checklist de contratos de API
215
+
216
+ - [ ] Todos os endpoints retornam `{ data, meta }` via interceptor global
217
+ - [ ] Listas paginadas usam `PaginationDto` e `paginate()` helper
218
+ - [ ] Erros usam as exceções padrão do NestJS (nunca retornar erro genérico)
219
+ - [ ] `password` e `hashedRefreshToken` NUNCA no response
220
+ - [ ] Swagger configurado com exemplos de request/response
221
+ - [ ] IDs sempre UUID — nunca sequencial
@@ -2,6 +2,20 @@
2
2
 
3
3
  Complementa o **SKILL.md** com arquivos base: API, auth e rotas.
4
4
 
5
+ ## ⚠️ SEGURANÇA — Armazenamento de tokens (OWASP A02)
6
+
7
+ > **Nunca use `localStorage` para armazenar tokens JWT.**
8
+ > localStorage é acessível por qualquer JavaScript da página — vulnerável a XSS.
9
+
10
+ **Estratégia correta:**
11
+ - **Access token** → memória (variável de estado React, nunca persistida)
12
+ - **Refresh token** → cookie `httpOnly` (configurado pelo backend, inacessível via JS)
13
+ - `withCredentials: true` no Axios para enviar o cookie automaticamente
14
+
15
+ O backend deve configurar o refresh token como cookie `httpOnly; Secure; SameSite=Strict`.
16
+
17
+ ---
18
+
5
19
  ## src/services/api.ts
6
20
 
7
21
  ```typescript
@@ -9,22 +23,39 @@ import axios from 'axios';
9
23
 
10
24
  const api = axios.create({
11
25
  baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api/v1',
12
- withCredentials: true,
26
+ withCredentials: true, // envia cookie httpOnly com refresh token automaticamente
13
27
  });
14
28
 
29
+ // O access token fica apenas em memória — injetado pelo AuthContext
30
+ let _accessToken: string | null = null;
31
+
32
+ export const setAccessToken = (token: string | null) => { _accessToken = token; };
33
+
15
34
  api.interceptors.request.use((config) => {
16
- const token = localStorage.getItem('access_token');
17
- if (token) config.headers.Authorization = `Bearer ${token}`;
35
+ if (_accessToken) config.headers.Authorization = `Bearer ${_accessToken}`;
18
36
  return config;
19
37
  });
20
38
 
21
39
  api.interceptors.response.use(
22
40
  (r) => r,
23
41
  async (error) => {
24
- // Opcional: refresh token + fila de retries — alinhar com backend
25
- if (error.response?.status === 401) {
26
- localStorage.removeItem('access_token');
27
- window.location.href = '/login';
42
+ const original = error.config;
43
+ if (error.response?.status === 401 && !original._retry) {
44
+ original._retry = true;
45
+ try {
46
+ // Tenta renovar via cookie httpOnly (sem enviar token no body)
47
+ const { data } = await axios.post(
48
+ `${import.meta.env.VITE_API_URL}/auth/refresh`,
49
+ {},
50
+ { withCredentials: true },
51
+ );
52
+ setAccessToken(data.access_token);
53
+ original.headers.Authorization = `Bearer ${data.access_token}`;
54
+ return api(original);
55
+ } catch {
56
+ setAccessToken(null);
57
+ window.location.href = '/login';
58
+ }
28
59
  }
29
60
  return Promise.reject(error);
30
61
  },
@@ -43,15 +74,15 @@ import {
43
74
  useCallback,
44
75
  type ReactNode,
45
76
  } from 'react';
46
- import api from '../services/api';
77
+ import api, { setAccessToken } from '../services/api';
47
78
 
48
- type User = { id: string; email: string; name: string };
79
+ type User = { id: string; email: string; name: string; role: string };
49
80
 
50
81
  type AuthContextValue = {
51
82
  user: User | null;
52
83
  loading: boolean;
53
84
  login: (email: string, password: string) => Promise<void>;
54
- logout: () => void;
85
+ logout: () => Promise<void>;
55
86
  };
56
87
 
57
88
  const AuthContext = createContext<AuthContextValue | null>(null);
@@ -64,16 +95,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
64
95
  setLoading(true);
65
96
  try {
66
97
  const { data } = await api.post('/auth/login', { email, password });
67
- localStorage.setItem('access_token', data.access_token);
98
+ // Access token apenas em memória — NÃO salvar em localStorage
99
+ setAccessToken(data.access_token);
68
100
  setUser(data.user);
101
+ // O refresh token é salvo como cookie httpOnly pelo backend automaticamente
69
102
  } finally {
70
103
  setLoading(false);
71
104
  }
72
105
  }, []);
73
106
 
74
- const logout = useCallback(() => {
75
- localStorage.removeItem('access_token');
76
- setUser(null);
107
+ const logout = useCallback(async () => {
108
+ try {
109
+ await api.post('/auth/logout'); // invalida refresh token no banco + limpa cookie
110
+ } finally {
111
+ setAccessToken(null);
112
+ setUser(null);
113
+ }
77
114
  }, []);
78
115
 
79
116
  return (
@@ -150,4 +187,14 @@ createRoot(document.getElementById('root')!).render(
150
187
  );
151
188
  ```
152
189
 
190
+ ## Checklist de segurança de auth no frontend
191
+
192
+ - [ ] Access token NUNCA em localStorage ou sessionStorage
193
+ - [ ] Access token apenas em variável de módulo (memória)
194
+ - [ ] Refresh token configurado como cookie httpOnly pelo backend
195
+ - [ ] `withCredentials: true` no Axios
196
+ - [ ] Interceptor de 401 com retry automático via refresh
197
+ - [ ] Logout invalida token no backend + limpa estado local
198
+ - [ ] Não logar tokens em console.log
199
+
153
200
  Substitua os placeholders de página pelos componentes reais em `pages/`.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: testing
3
+ description: Estratégia e implementação de testes para NestJS + React + React Native. Use quando o desenvolvedor precisar configurar testes unitários, de integração ou e2e, escrever specs, mockar dependências ou configurar cobertura de código.
4
+ ---
5
+
6
+ # Testing
7
+
8
+ Stack: Jest (backend) · Vitest (frontend) · Supertest · Testing Library · Playwright (e2e)
9
+
10
+ ## Pirâmide de testes adotada
11
+
12
+ ```
13
+ /\
14
+ /e2e\ ← Playwright — fluxos críticos (login, checkout)
15
+ /------\
16
+ /integra-\ ← Supertest — endpoints reais com banco de teste
17
+ / ção \
18
+ /------------\
19
+ / unitários \ ← Jest/Vitest — lógica isolada, services, utils
20
+ /________________\
21
+ ```
22
+
23
+ > Regra: mais unitários, menos e2e. E2e é caro — use apenas para fluxos críticos de negócio.
24
+
25
+ ## Backend — Jest + Supertest
26
+
27
+ ```bash
28
+ # Já vem com NestJS — verificar se está instalado
29
+ npm install --save-dev jest @types/jest ts-jest supertest @types/supertest
30
+ ```
31
+
32
+ ```typescript
33
+ // jest.config.ts
34
+ export default {
35
+ moduleFileExtensions: ['js', 'json', 'ts'],
36
+ rootDir: 'src',
37
+ testRegex: '.*\\.spec\\.ts$',
38
+ transform: { '^.+\\.(t|j)s$': 'ts-jest' },
39
+ collectCoverageFrom: ['**/*.(t|j)s'],
40
+ coverageDirectory: '../coverage',
41
+ testEnvironment: 'node',
42
+ };
43
+ ```
44
+
45
+ ## Frontend — Vitest + Testing Library
46
+
47
+ ```bash
48
+ npm install --save-dev vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
49
+ ```
50
+
51
+ ```typescript
52
+ // vite.config.ts — adicionar
53
+ test: {
54
+ globals: true,
55
+ environment: 'jsdom',
56
+ setupFiles: './src/test/setup.ts',
57
+ }
58
+ ```
59
+
60
+ ```typescript
61
+ // src/test/setup.ts
62
+ import '@testing-library/jest-dom';
63
+ ```
64
+
65
+ ## E2E — Playwright
66
+
67
+ ```bash
68
+ npm install --save-dev @playwright/test
69
+ npx playwright install chromium
70
+ ```
71
+
72
+ ## Arquivos detalhados desta skill
73
+
74
+ - **backend.md** — Testes unitários e de integração para NestJS (services, guards, controllers)
75
+ - **frontend.md** — Testes de componentes React com Testing Library
76
+ - **e2e.md** — Fluxos e2e com Playwright
77
+
78
+ Leia os arquivos de detalhe para implementar cada camada da pirâmide.
@@ -0,0 +1,161 @@
1
+ # Testing — Backend (NestJS + Jest)
2
+
3
+ ## Teste unitário — Service
4
+
5
+ ```typescript
6
+ // src/auth/auth.service.spec.ts
7
+ import { Test, TestingModule } from '@nestjs/testing';
8
+ import { AuthService } from './auth.service';
9
+ import { PrismaService } from '../prisma/prisma.service';
10
+ import { JwtService } from '@nestjs/jwt';
11
+ import { ConfigService } from '@nestjs/config';
12
+ import * as bcrypt from 'bcrypt';
13
+
14
+ const mockPrisma = {
15
+ user: {
16
+ findUnique: jest.fn(),
17
+ create: jest.fn(),
18
+ update: jest.fn(),
19
+ },
20
+ };
21
+
22
+ describe('AuthService', () => {
23
+ let service: AuthService;
24
+
25
+ beforeEach(async () => {
26
+ const module: TestingModule = await Test.createTestingModule({
27
+ providers: [
28
+ AuthService,
29
+ { provide: PrismaService, useValue: mockPrisma },
30
+ { provide: JwtService, useValue: { signAsync: jest.fn().mockResolvedValue('token') } },
31
+ { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('secret') } },
32
+ ],
33
+ }).compile();
34
+
35
+ service = module.get<AuthService>(AuthService);
36
+ });
37
+
38
+ afterEach(() => jest.clearAllMocks());
39
+
40
+ describe('login', () => {
41
+ it('deve retornar tokens com credenciais válidas', async () => {
42
+ const hashedPassword = await bcrypt.hash('senha123', 12);
43
+ mockPrisma.user.findUnique.mockResolvedValue({
44
+ id: 'uuid-1',
45
+ email: 'user@test.com',
46
+ password: hashedPassword,
47
+ role: 'USER',
48
+ });
49
+ mockPrisma.user.update.mockResolvedValue({});
50
+
51
+ const result = await service.login('user@test.com', 'senha123');
52
+
53
+ expect(result).toHaveProperty('access_token');
54
+ expect(result).toHaveProperty('refresh_token');
55
+ });
56
+
57
+ it('deve lançar UnauthorizedException com credenciais inválidas', async () => {
58
+ mockPrisma.user.findUnique.mockResolvedValue(null);
59
+
60
+ await expect(service.login('x@x.com', 'errado')).rejects.toThrow(
61
+ 'Credenciais inválidas',
62
+ );
63
+ });
64
+ });
65
+ });
66
+ ```
67
+
68
+ ## Teste de integração — Controller com banco real
69
+
70
+ ```typescript
71
+ // test/auth.e2e-spec.ts
72
+ import { Test, TestingModule } from '@nestjs/testing';
73
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
74
+ import * as request from 'supertest';
75
+ import { AppModule } from '../src/app.module';
76
+ import { PrismaService } from '../src/prisma/prisma.service';
77
+
78
+ describe('AuthController (integração)', () => {
79
+ let app: INestApplication;
80
+ let prisma: PrismaService;
81
+
82
+ beforeAll(async () => {
83
+ const module: TestingModule = await Test.createTestingModule({
84
+ imports: [AppModule],
85
+ }).compile();
86
+
87
+ app = module.createNestApplication();
88
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
89
+ await app.init();
90
+
91
+ prisma = module.get<PrismaService>(PrismaService);
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await prisma.$executeRaw`TRUNCATE TABLE users CASCADE`;
96
+ await app.close();
97
+ });
98
+
99
+ describe('POST /auth/register', () => {
100
+ it('deve registrar um novo usuário', () => {
101
+ return request(app.getHttpServer())
102
+ .post('/auth/register')
103
+ .send({ name: 'Teste', email: 'teste@email.com', password: 'Senha@123' })
104
+ .expect(201)
105
+ .expect((res) => {
106
+ expect(res.body.data).toHaveProperty('id');
107
+ expect(res.body.data).not.toHaveProperty('password');
108
+ });
109
+ });
110
+
111
+ it('deve rejeitar email duplicado', () => {
112
+ return request(app.getHttpServer())
113
+ .post('/auth/register')
114
+ .send({ name: 'Teste', email: 'teste@email.com', password: 'Senha@123' })
115
+ .expect(409);
116
+ });
117
+ });
118
+
119
+ describe('POST /auth/login', () => {
120
+ it('deve retornar tokens com credenciais válidas', async () => {
121
+ const res = await request(app.getHttpServer())
122
+ .post('/auth/login')
123
+ .send({ email: 'teste@email.com', password: 'Senha@123' })
124
+ .expect(200);
125
+
126
+ expect(res.body.data).toHaveProperty('access_token');
127
+ // Refresh token deve vir como cookie httpOnly, não no body
128
+ expect(res.headers['set-cookie']).toBeDefined();
129
+ });
130
+ });
131
+ });
132
+ ```
133
+
134
+ ## Banco de dados de teste
135
+
136
+ ```env
137
+ # .env.test
138
+ DATABASE_URL="postgresql://user:pass@localhost:5432/myapp_test"
139
+ ```
140
+
141
+ ```json
142
+ // package.json
143
+ {
144
+ "scripts": {
145
+ "test": "jest",
146
+ "test:watch": "jest --watch",
147
+ "test:cov": "jest --coverage",
148
+ "test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json",
149
+ "test:e2e:watch": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --watch"
150
+ }
151
+ }
152
+ ```
153
+
154
+ ## Checklist de testes backend
155
+
156
+ - [ ] Cada service tem `.spec.ts` com casos de sucesso e erro
157
+ - [ ] Banco de dados de teste separado (`.env.test`)
158
+ - [ ] Dados de teste limpos após cada suite (`afterAll` com TRUNCATE)
159
+ - [ ] Senhas nunca hardcoded nos testes — usar factory functions
160
+ - [ ] Cobertura mínima de 70% nos services
161
+ - [ ] Guards testados isoladamente