Prompts para Testear Autenticación y Autorización con IA
10 prompts para testear auth con IA: JWT, sesiones, RBAC, expiración de tokens, IDOR y control de acceso. Los errores que los tests no detectan.
Tabla de contenidos
El endpoint que devolvía los datos del cliente. Con userId hardcodeado en la query. El tester manual que hacía las pruebas usaba siempre el mismo usuario, así que nunca lo vio. El atacante tardó once minutos en encontrarlo con un for loop sobre el campo id.
Ese es el patrón, y se repite: los tests de autenticación se centran en el login y las APIs se centran en “mi” recurso. El IDOR —Insecure Direct Object Reference, también llamado BOLA— es el fallo de seguridad más caro de las APIs, y es precisamente el que los equipos de desarrollo casi nunca prueban.
La autenticación es la puerta. La autorización es la cerradura de cada habitación. Pruebas la puerta con cuidado y te olvidas de las habitaciones.
Por qué la IA es especialmente útil aquí
La lógica de autorización tiene tres propiedades que la hacen ideal para prompts: es repetitiva, es enumerable y es boring de escribir. Hay 40 endpoints y cada uno necesita exactamente los mismos cinco tests. Nadie los escribe porque son aburridos. Un modelo los escribe en un minuto, y esa es toda la propuesta.
El riesgo es el opuesto: una IA que genera auth tests sin entender tu modelo de permisos produce 40 tests que comprueban que un 401 funciona, y que no detectan ni un IDOR.
Anatomía de un fallo de autorización
Antes de los prompts, el mapa. Estos son los fallos, ordenados por lo que cuesta:
| Fallo | Qué es | Coste típico |
|---|---|---|
| IDOR / BOLA | GET /api/invoices/123 devuelve la factura de otro si no compruebas ownership | Crítico: filtrado de datos personales |
| Ausencia de verificación de rol | El endpoint comprueba que hay token pero no si es admin | Crítico: escalada de privilegios |
| Mass assignment | Aceptas el body entero y guardas role: "admin" | Crítico: te conviertes en admin con un POST |
| Token no revocado | Logout no invalida nada; el token vive hasta expirar | Alto: robo de token |
| Refresh token sin rotación | El mismo refresh token sirve para siempre | Alto |
| Firmar con el algoritmo que elijó el atacante | Aceptas alg: none o HS256 con la clave pública como secreto | Crítico |
| Comparación no constante | token === sessionToken filtra por timing | Medio |
| Cookie sin flags | Sin HttpOnly, sin SameSite, sin Secure | Alto |
| Rate limit ausente en login | Fuerza bruta ilimitada | Alto |
| Secretos en el repositorio | JWT_SECRET commiteado | Crítico |
Reglas de oro antes de generar tests de auth
- Un endpoint, un prompt. La tentación es pegarle los 40 endpoints y pedir la suite entera. Sale más largo, menos legible y nadie revisa nada.
- Exige la matriz de roles explícita. Si no le dices qué roles existen y qué pueden hacer cada uno, el modelo se inventa un modelo de permisos y testea el suyo, no el tuyo.
- Pide el test negativo primero. “Confirma que funciona” produce tests/db/db/db. “Confirma que NO puede” produce los tests que valen.
- Los tiempos van en un reloj falso. Los tests de expiración con
setTimeoutreal son los que más flakes generan y los que nadie ejecuta en CI. - Ningún test de auth contra un servicio de email real. Mockea el envío. Un test de 15 segundos con espera SMTP es un test que alguien va a desactivar.
1. La matriz de autorización: el prompt que más valor da
Este es el prompt central. Antes de testear nada, tienes que tener la matriz escrita. La mayoría de equipos no la tiene, y por eso sus tests no cubren nada.
Voy a implementar tests de autorización para mi API y necesito construir la matriz
de control de acceso. Estos son los datos de mi sistema:
Endpoints protegidos:
[lista completa: método, ruta, qué devuelve]
Roles/permisos que existen:
[lista: admin, manager, user, viewer — con lo que significa cada uno en mi dominio]
Relaciones entre recursos:
- Un pedido pertenece a un usuario (ownerId)
- Un pedido pertenece a un tenant
- Un manager ve los pedidos de SU tenant, no de otros
- Un viewer solo puede leer, nunca escribir
[lista las relaciones reales]
Genera la matriz de autorización completa en formato tabla:
Endpoint | Rol | Recurso propio | Recurso de otro usuario | Recurso de otro tenant | Sin token | Token inválido | Token expirado
Para cada celda pon: PERMITIDO / DENEGADO / N/A
Después:
1. Detecta los endpoints donde NO hay ninguna regla clara. Un endpoint con la fila toda
"DENEGADO" y la columna propia vacía es un endpoint sin propietario definido
2. Señala los endpoints donde la lógica depende solo del rol y NO del recurso:
esos son candidatos a IDOR, porque un user puede leer lo de otro user
3. Ordena los endpoints por riesgo: primero los que exponen datos personales o dinero
4. Dime cuántos tests hay que escribir en total y cuántos son solo de autorización
Por qué funciona: convierte “testea mi auth” en una enumeración finita. El modelo tiene la estructura mental de las matrices de permisos —quién puede hacer qué sobre qué recurso— y con tus datos de entrada produce una tabla completa en un momento.
Ejemplo real de salida:
| Endpoint | admin | user (propio) | user (otro user) | user (otro tenant) | Sin token |
|---|---|---|---|---|---|
| GET /api/invoices | CRUD todas | READ | DENEGADO | DENEGADO | DENEGADO |
| POST /api/invoices | CRUD todas | CREATE | N/A | DENEGADO | DENEGADO |
| DELETE /api/invoices/:id | CRUD todas | DENEGADO | DENEGADO | DENEGADO | DENEGADO |
| PATCH /api/users/me | CRUD todas | READ+UPDATE | DENEGADO | DENEGADO | DENEGADO |
| PATCH /api/users/:id/role | UPDATE roles | DENEGADO | DENEGADO | DENEGADO | DENEGADO |
| GET /api/admin/stats | READ | DENEGADO | DENEGADO | DENEGADO | DENEGADO |
Seis endpoints, treinta casos. Los tres de IDOR están en negrita y son los que casi nadie habría escrito.
2. Tests de IDOR: el endpoint más caro de testear
Este es el prompt que más directamente previene el incidente con el que abrí el artículo. La clave es pedir la batería completa por endpoint, no solo el caso feliz.
Necesito una batería completa de tests anti-IDOR para estos endpoints:
Endpoints:
- GET /api/invoices/:id → devuelve una factura
- PATCH /api/invoices/:id → actualiza estado, notas, notas de pago
- DELETE /api/invoices/:id
- GET /api/projects/:id/files → descarga un fichero
- POST /api/projects/:id/members → añade un miembro al proyecto
Mi modelo de autorización:
- La factura tiene ownerId. Solo el dueño y un admin del mismo tenant pueden verla.
- Los ficheros del proyecto: cualquier miembro del proyecto, sea cual sea su tenant
(es un proyecto compartido), pero NUNCA un no-miembro
- Los miembros del proyecto: solo el owner del proyecto puede añadir
Fixture: en setup() creo 2 tenants (A y B), con 2 usuarios cada uno, y un proyecto
donde el user A1 es miembro y el user B1 no.
Escribe el test de Supertest para cada endpoint que cubra EXACTAMENTE esto:
1. GET sin token → 401
2. GET con token de B1 sobre recurso de A1 → 403 (no 404, explícito, quiero ver el fallo)
3. GET con token válido sobre recurso propio → 200
4. GET con token de un usuario del MISMO tenant pero distinto → según la regla que definí
5. PATCH del recurso ajeno → 403 Y el recurso NO ha cambiado en la base de datos.
Este último assert es el importante: que el 403 venga después de haber escrito
6. DELETE del recurso ajeno → 403 Y sigue existiendo
7. Un usuario de otro tenant con el mismo rol → 403
Dame el código de setup() completo con los fixtures y los 7 tests por endpoint.
Nada de "y análogamente para el resto".
Por qué funciona: el punto 5 es el que la mayoría de tests se salta. Un PATCH que valida los permisos después de escribir en la base de datos devuelve 403 y deja el registro modificado. Es un bug real que pasa en producción.
Ejemplo real:
import request from 'supertest';
import { before, describe, it, expect } from 'vitest';
import { app } from '../src/app.js';
import { db } from '../src/db.js';
let tokenA1, tokenB1, invoiceOfA1, invoiceId;
before(async () => {
await db.$transaction([
db.tenant.create({ data: { id: 'A', name: 'Tenant A' } }),
db.tenant.create({ data: { id: 'B', name: 'Tenant B' } }),
db.user.create({ data: { id: 'A1', tenantId: 'A', email: 'a1@t.com', role: 'user' } }),
db.user.create({ data: { id: 'B1', tenantId: 'B', email: 'b1@t.com', role: 'user' } }),
]);
const inv = await db.invoice.create({
data: { ownerId: 'A1', tenantId: 'A', total: 500, status: 'paid' },
});
invoiceId = inv.id;
tokenA1 = await login('a1@t.com');
tokenB1 = await login('b1@t.com');
});
describe('GET /api/invoices/:id — anti-IDOR', () => {
it('rechaza sin token con 401', async () => {
await request(app).get(`/api/invoices/${invoiceId}`).expect(401);
});
it('rechaza con token inválido con 401', async () => {
await request(app)
.get(`/api/invoices/${invoiceId}`)
.set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiJ9.abc.def')
.expect(401);
});
it('permite al propietario con 200', async () => {
await request(app)
.get(`/api/invoices/${invoiceId}`)
.set('Authorization', `Bearer ${tokenA1}`)
.expect(200);
});
it('BLOQUEA a un usuario de otro tenant con 403', async () => {
const res = await request(app)
.get(`/api/invoices/${invoiceId}`)
.set('Authorization', `Bearer ${tokenB1}`);
expect(res.status).toBe(403);
expect(res.body.data).toBeUndefined(); // ni siquiera partially filtrado
});
it('BLOQUEA a un usuario del mismo tenant con 403', async () => {
const tokenA2 = await login('a2@t.com');
await request(app)
.get(`/api/invoices/${invoiceId}`)
.set('Authorization', `Bearer ${tokenA2}`)
.expect(403);
});
});
describe('PATCH /api/invoices/:id — anti-IDOR', () => {
it('403 y SIN modificar el recurso cuando el permiso se comprueba tarde', async () => {
await request(app)
.patch(`/api/invoices/${invoiceId}`)
.set('Authorization', `Bearer ${tokenB1}`)
.send({ status: 'paid', total: 0 }) // intento de manipular el importe
.expect(403);
// El assert que importa: el recurso está intacto
const after = await db.invoice.findUnique({ where: { id: invoiceId } });
expect(after.total).toBe(500);
expect(after.status).toBe('paid');
});
});
3. Testear expiración y revocación de tokens sin esperas reales
Los tests de expiración son los que más se posponen, porque esperar 15 minutos por un test no es una opción. La solución es el reloj falso y una función de expiración parametrizable.
Necesito testear expiración y revocación de tokens JWT sin esperar en tiempo real.
Mi configuración actual:
- ACCESS_TOKEN_TTL: "15m"
- REFRESH_TOKEN_TTL: "30d"
- El JWT se firma con jsonwebtoken usando process.env.JWT_SECRET
- El refresh token se guarda hasheado en la tabla sessions con un campo expiresAt
- El logout hace DELETE de la sesión en la tabla
Problemas que quiero resolver:
1. Cómo hacer que los tests usen TTL de 1 segundo para que el test tarde 1 segundo,
no 15 minutos. ¿Variable de entorno por test? ¿Inyección del servicio?
2. Cómo testear que un token expirado da 401 y NO 403. Es un detalle que siempre falla
3. Cómo testear revocación: si borro la sesión, el access token sigue siendo válido
hasta que expira. ¿Cómo diseño esto y cómo lo testeo?
4. Cómo testear rotación de refresh tokens: un refresh token usado dos veces
debe fallar la segunda vez, y además revocar toda la familia (detección de robo)
5. Cómo testear la carrera: dos peticiones simultáneas con el mismo refresh token
Escribe el código con las 5 situaciones, usando fake timers o inyección de reloj,
y la refactorización mínima que hace posible testear el TTL sin esperar.
Por qué funciona: el modelo conoce jsonwebtoken y sus opciones expiresIn/exp, y sabe que la forma testable es inyectar la dependencia del reloj o del TTL en lugar de esperar. La parte de rotación de refresh tokens y detección de robo la redacta como una familia de tokens, que es el patrón real.
Ejemplo real con inyección del TTL:
// src/auth/tokenService.js — el TTL es inyectable
import jwt from 'jsonwebtoken';
export function createTokenService({ accessTtl = '15m', refreshTtl = '30d' } = {}) {
return {
signAccess(payload) {
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: accessTtl,
algorithm: 'HS256',
});
},
verifyAccess(token) {
// Fijamos el algoritmo: sin esto, acepta alg:none
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
},
};
}
// En los tests: TTL de 1 segundo
const fastTokens = createTokenService({ accessTtl: '1s' });
describe('expiración y revocación', () => {
it('acepta el token recién emitido', async () => {
const token = fastTokens.signAccess({ sub: 'A1' });
await request(app).get('/api/invoices').set('Authorization', `Bearer ${token}`).expect(200);
});
it('devuelve 401 y no 403 cuando el token ha expirado', async () => {
const token = fastTokens.signAccess({ sub: 'A1' });
await new Promise((r) => setTimeout(r, 1100)); // 1,1 s, no 15 minutos
await request(app)
.get('/api/invoices')
.set('Authorization', `Bearer ${token}`)
.expect(401); // 401 = no autenticado
});
it('rechaza un token firmado con otro secreto', async () => {
const otro = jwt.sign({ sub: 'A1' }, 'secreto-del-atacante', { expiresIn: '15m' });
await request(app).get('/api/invoices').set('Authorization', `Bearer ${otro}`).expect(401);
});
it('rechaza un token con alg: none', async () => {
// Payload en base64url, sin firma
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'A1' })).toString('base64url');
const token = `${header}.${payload}.`;
await request(app).get('/api/invoices').set('Authorization', `Bearer ${token}`).expect(401);
});
it('el refresh token es de un solo uso: reutilizarlo revoca la familia', async () => {
const original = await issueRefreshToken('A1', 'family-1');
const first = await useRefreshToken(original); // OK, rota
expect(first.status).toBe(200);
const reused = await useRefreshToken(original); // ATAQUE: reutilizado
expect(reused.status).toBe(401);
// Toda la familia queda invalidada, incluido el access token ya emitido
const sesion = await db.session.findUnique({ where: { familyId: 'family-1' } });
expect(sesion.revokedAt).not.toBeNull();
});
});
4. Testear bcrypt y argon2: coste, salts y timing
El hashing de contraseñas es donde más se relaja la seguridad sin testearla. Un factor de coste de 8 en producción es un fallo que ningún test detecta salvo que midas el tiempo.
Necesito testear el hashing de contraseñas. Contexto:
- Ahora uso bcrypt con saltRounds: 10
- Estoy considerando migrar a argon2id
- Coste actual medido: [X] ms por hash en mi servidor
Quiero:
1. Tests que verifiquen que el hash NO contiene la contraseña en claro y que el salt
es único por hash (dos usuarios con la misma contraseña → hashes distintos)
2. Un test que mida el tiempo de hash y FALLE si baja del umbral. Si el coste baja de 10
a 8, el hash pasa de 100ms a 25ms y eso debe romper el pipeline, no pasar inadvertido
3. La comparación de contraseñas sin leaking de timing: cómo hacer un test que detecte
si alguien usa === en lugar de una comparación constante
4. La verificación de contraseña con dummy hash cuando el usuario no existe: sin eso,
el tiempo de respuesta del login delata qué emails están registrados
5. Un test de límites: contraseña de 72 bytes con bcrypt (trunca en silencio),
contraseña vacía, contraseña con null byte
6. Comparación de recursos entre bcrypt 10, bcrypt 12, argon2id con los parámetros
por defecto. Dame cifras reales de tiempo y memoria, no estimaciones
Dame los tests en el framework que uso y el código de hashPassword/verifyPassword
refactorizado para que el coste y el hasher sean inyectables.
Por qué funciona: los límites de 72 bytes de bcrypt y el timing del login son los dos hallazgos que la IA conoce y que la gente pasa por alto. Y el test del coste convierte “no bajes el factor” en algo que CI verifica.
Ejemplo real:
import { describe, it, expect } from 'vitest';
import bcrypt from 'bcryptjs';
import { verifyPassword, hashPassword, verifyPasswordDummy } from '../src/auth/hash.js';
describe('hash de contraseñas', () => {
it('el mismo password genera hashes distintos (salt único)', async () => {
const h1 = await hashPassword('mismoPassword');
const h2 = await hashPassword('mismoPassword');
expect(h1).not.toBe(h2);
expect(await verifyPassword('mismoPassword', h1)).toBe(true);
expect(await verifyPassword('mismoPassword', h2)).toBe(true);
});
it('el coste nunca baja del mínimo de política', async () => {
const MIN_MS = 40; // si el hash tarda menos, alguien bajó los rounds
const start = performance.now();
await hashPassword('test-password-para-medir');
const elapsed = performance.now() - start;
expect(elapsed).toBeGreaterThan(MIN_MS);
});
it('rechaza contraseñas de más de 72 bytes en lugar de truncar en silencio', async () => {
await expect(hashPassword('a'.repeat(100))).rejects.toThrow(/72|largo/i);
});
it('el login de un usuario inexistente tarda lo mismo que el de uno existente', async () => {
const realHash = await hashPassword('password-real');
const inicioReal = performance.now();
await verifyPassword('cualquier-cosa', realHash);
const tiempoReal = performance.now() - inicioReal;
const inicioDummy = performance.now();
await verifyPasswordDummy('cualquier-cosa');
const tiempoDummy = performance.now() - inicioDummy;
// La diferencia no debe ser reveladora (>2x sugiere enumeración de usuarios)
const ratio = tiempoDummy / tiempoReal;
expect(ratio).toBeGreaterThan(0.5);
expect(ratio).toBeLessThan(2);
});
});
5. Tests de cookies: HttpOnly, SameSite, Secure y flujo OAuth
El 90% de los equipos nunca hace un assert sobre las cabeceras Set-Cookie. Es la parte del auth que más ataques triviales permite.
Quiero testear la seguridad de las cookies de mi aplicación.
Configuración actual de mi cookie de sesión:
Set-Cookie: session=<token>; Path=/; HttpOnly; Secure; SameSite=Lax
[nombre real de la cookie y atributos reales]
Quiero:
1. Tests que asserten cada atributo por separado, leyendo el header Set-Cookie crudo
del response (no la API de cookies del supertest, que a veces normaliza)
2. Un test que falle si alguien quita HttpOnly en un commit
3. El test del flujo de redirección post-login: ¿la cookie se fija ANTES de la
redirección o después? Si es después, hay una ventana de race enSomewhere
4. El comportamiento con SameSite=None: exige Secure, y abre una ventana de CSRF.
¿Cómo lo permito solo para los casos que lo necesitan?
5. Tests del callback OAuth: ¿se puede modificar el redirect_uri en la URL manipulada?
Ese es el open redirect clásico
6. Cómo testear que la sesión se invalida al cerrar el navegador o al hacer logout,
y que el logout limpia la cookie (no solo invalida en servidor)
7. El caso del SameSite=None + subdomain: por qué es peligroso y cómo testearlo
Ejemplo real:
describe('cookies de sesión', () => {
it('la cookie de sesión tiene HttpOnly, Secure y SameSite', async () => {
const res = await request(app).post('/api/auth/login').send({
email: 'a1@t.com', password: 'Password123!',
});
const raw = res.headers['set-cookie'][0];
expect(raw).toMatch(/HttpOnly/i);
expect(raw).toMatch(/Secure/i);
expect(raw).toMatch(/SameSite=Lax/i);
expect(raw).toMatch(/Path=\//i);
});
it('el logout limpia la cookie, no solo invalida en servidor', async () => {
const res = await request(app).post('/api/auth/logout').expect(200);
const raw = res.headers['set-cookie'][0];
expect(raw).toMatch(/session=;/); // valor vacío
expect(raw).toMatch(/Max-Age=0/i); // expiración inmediata
expect(raw).toMatch(/HttpOnly/i);
});
it('el callback OAuth rechaza un redirect_uri no registrado (open redirect)', async () => {
const evil = 'https://evil.com/steal';
const res = await request(app)
.get(`/api/auth/callback?code=abc&state=xyz`)
.set('Cookie', 'oauth_state=xyz; redirect_uri=' + encodeURIComponent(evil));
expect(res.status).toBe(400);
expect(res.headers.location).toBeUndefined();
});
});
6. Mass assignment: el body que se convierte en admin
Este es el segundo fallo caro después del IDOR, y el más subestimado. Un Object.assign(user, req.body) convierte tu API en un panel de administración abierto.
Revisa mi API contra mass assignment. Contexto:
Mi endpoint de creación de usuario:
[pega el código del controller y del schema de validación]
Mi endpoint de actualización:
[pega el código]
Mi schema de validación (Zod):
[pega el schema]
Quiero que:
1. Enumeres todos los campos que un atacante podría inyectar en el body y que acabarían
guardados en la base de datos. Prioriza: role, isAdmin, tenantId, userId,
emailVerified, balance, credits, internalNotes
2. Para cada campo peligroso, el test que demuestra que NO se puede escribir
3. El test del caso real: POST /api/users con {email, name, role: "admin"}
→ ¿la respuesta crea un admin o un user normal?
4. La diferencia entre "allowlist explícita de campos" (destructuring) y
"blocklist de campos prohibidos". Dame el código de las dos y por qué
la blocklist es una mala idea
5. Cómo hacer que el test falle si alguien añade `role` al schema de Zod sin querer
6. Lo mismo para PATCH: el update parcial con campos no permitidos
Incluye un caso donde el campo sí es legítimo en un contexto (un admin de verdad
puede cambiar roles) pero no en otro. ¿Cómo se Testea eso sin duplicar endpoints?
Ejemplo real:
// ❌ El body's keys controlan lo que se guarda
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const user = await db.user.update({ where: { id: req.params.id }, data: req.body });
res.json(user);
});
// ✅ Allowlist explícita: lo que no está en la lista no se toca, nunca
const EDITABLE_BY_SELF = ['name', 'bio', 'avatarUrl'] as const;
const EDITABLE_BY_ADMIN = [...EDITABLE_BY_SELF, 'role', 'tenantId', 'emailVerified'];
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const fields = req.user.role === 'admin' ? EDITABLE_BY_ADMIN : EDITABLE_BY_SELF;
const data = Object.fromEntries(
Object.entries(req.body).filter(([key]) => fields.includes(key as never)),
);
// El id del recurso NUNCA sale del body, sale del path validado
const user = await db.user.update({
where: { id: req.user.role === 'admin' ? req.params.id : req.user.id },
data,
});
res.json(user);
});
Y el test que lo blinda:
it('ignora role y tenantId en el body de un usuario normal', async () => {
const res = await request(app)
.patch(`/api/users/${userA1.id}`)
.set('Authorization', `Bearer ${tokenA1}`)
.send({ name: 'Nombre nuevo', role: 'admin', tenantId: 'B' })
.expect(200);
expect(res.body.name).toBe('Nombre nuevo');
expect(res.body.role).toBe('user'); // no cambió
expect(res.body.tenantId).toBe('A'); // no cambió
const enDb = await db.user.findUnique({ where: { id: userA1.id } });
expect(enDb.role).toBe('user');
});
7. RBAC frente a ABAC: pedir al modelo la comparación con tu caso
RBAC es roles. ABAC es atributos (tenant, hora, recurso, ubicación). La mayoría de sistemas reales acaba en ABAC a medio camino, y los tests no cubren la parte dinámica.
Mi sistema de permisos empezó como RBAC puro y se ha convertido en algo más complejo.
Dime exactamente en qué y cómo, y escríbeme los tests.
Lo que tengo hoy:
- Roles: admin, manager, user, viewer
- PERO: un manager solo puede ver datos de su propio tenant
- PERO: un user solo puede editar un recurso si es el dueño
- PERO: los pagos son inmutables después de 24h, sea quien sea
- PERO: entre las 00:00 y las 06:00 solo admin puede hacer operaciones de escritura
Analiza:
1. Esto es RBAC, ABAC o un híbrido? Nómbralo con precisión
2. ¿Qué atributos necesito modelar explícitamente para que esto sea testeable?
Enuméralos: role, tenantId, ownerId, createdAt, timestamp actual
3. ¿Mi modelo actual de "if (role === 'admin')" en cada endpoint es sustainable?
¿Cuántos endpoints tengo ya con esa lógica duplicada?
4. Diseña una función de decisión única: authorize(user, action, resource) → boolean
¿Tiene sentido en mi caso o es sobreingeniería?
5. Los tests: para la regla de las 24h necesito reloj inyectado. Escríbemelo
6. Para la regla del horario, ¿testeo con fakes de hora o con fecha fija en el
entorno? ¿Qué recomienda cada opción?
7. Los 4 tests de la regla de las 24h: exactamente 24h, 24h+1ms, 23h59m, y un admin
que tampoco puede. Ese último es el que más se olvida
Ejemplo real: la regla de las 24 horas con reloj inyectado.
// src/domain/payments.js — el reloj es inyectable, no Date.now() directo
export function puedeEditarPago(pago, user, ahora = new Date()) {
const HORAS_BLOQUEO = 24;
const transcurridas = (ahora - pago.createdAt) / 3_600_000;
if (transcurridas >= HORAS_BLOQUEO) {
return { permitido: false, motivo: 'PAGO_BLOQUEADO_24H' };
}
if (user.role !== 'admin' && pago.ownerId !== user.id) {
return { permitido: false, motivo: 'NO_ES_DUENO' };
}
return { permitido: true };
}
describe('regla de bloqueo de pagos a las 24h', () => {
const t0 = new Date('2026-01-01T10:00:00Z');
const pago = { ownerId: 'A1', createdAt: t0 };
it('permite editar dentro de la ventana', () => {
const r = puedeEditarPago(pago, userA1, new Date(t0.getTime() + 23 * 3600_000 + 59 * 60_000));
expect(r.permitido).toBe(true);
});
it('bloquea exactamente en el límite de 24h', () => {
const r = puedeEditarPago(pago, userA1, new Date(t0.getTime() + 24 * 3600_000));
expect(r).toEqual({ permitido: false, motivo: 'PAGO_BLOQUEADO_24H' });
});
it('bloquea un milisegundo después del límite', () => {
const r = puedeEditarPago(pago, userA1, new Date(t0.getTime() + 24 * 3600_000 + 1));
expect(r.permitido).toBe(false);
});
it('el admin TAMBIÉN está bloqueado: la regla es temporal, no de rol', () => {
const r = puedeEditarPago(pago, adminUser, new Date(t0.getTime() + 25 * 3600_000));
expect(r).toEqual({ permitido: false, motivo: 'PAGO_BLOQUEADO_24H' });
});
});
8. Testear Auth.js / NextAuth en una app real
Si usas Auth.js, el punto ciego es que la mayoría de los tests cubren la página de login y ninguno cubre las rutas protegidas. Este prompt va directo ahí.
Uso Auth.js (NextAuth v5) en una app Next.js 15 App Router con Credentials y GitHub.
Configuración:
[muestra el auth.ts: providers, callbacks (jwt, session), session.strategy, pages]
Tengo estas rutas protegidas:
[middleware.ts y/o layouts que verifican la sesión]
Quiero:
1. Tests unitarios de los callbacks: jwt y session. Concretamente, ¿el token conserva
el role y el tenantId? Si no los conserva, ¿dónde los pierde?
2. Tests de la middleware: sin cookie de sesión → redirect a /login. Con cookie
válida a una ruta protegida → pasa. Con cookie de una ruta restringida
(ej: /admin) sin ser admin → ¿qué hace? No debería pasar
3. Tests de la página de login: ¿el error de credenciales filtra qué emails existen?
¿El mensaje es genérico?
4. El caso crítico: la sesión se firma con NEXTAUTH_SECRET. ¿Qué pasa si ese secreto
cambia en un deploy? ¿Cómo lo testeo sin romper nada?
5. Tests de la protección CSRF en las rutas de mutation
6. El escenario del race: el usuario cierra sesión en la pestaña A, la pestaña B
sigue con la cookie. ¿Cómo lo invalido server-side?
7. Cómo reutilizar el estado autenticado entre tests de Playwright sin hacer login
en cada uno. storageState, cookies, ¿qué es lo correcto aquí?
Y quiero que uses el helper de test que tú escribas para que cada test empiece
con un usuario en un estado conocido.
Ejemplo real con storageState de Playwright, que es lo que hace el login una sola vez:
// tests/auth.setup.ts — se ejecuta UNA vez antes de toda la suite
import { test as setup, expect } from '@playwright/test';
setup('autenticar usuario de test', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('e2e-user@test.com');
await page.getByLabel('Contraseña').fill('TestPassword123!');
await page.getByRole('button', { name: 'Iniciar sesión' }).click();
await expect(page.getByRole('button', { name: 'Mi cuenta' })).toBeVisible();
// Guarda cookies + localStorage en un fichero
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { storageState: 'playwright/.auth/user.json' }, // ya autenticado
dependencies: ['setup'],
},
],
});
Cada test empieza autenticado. Un E2E que tarda 2 minutos baja a 25 segundos, y con 200 tests son casi dos horas de CI.
9. Fuzzing de los endpoints de auth: donde los tests normales no llegan
Un fuzz test genera inputs aleatorios y estructuralmente inválidos. Los casos que no vas a pensar: cabeceras duplicadas, JWT con segmentos de tamaño distinto, caracteres Unicode en el email, bodies de 10 MB.
Quiero fuzzing sobre mis endpoints de autenticación para encontrar lo que mis tests
unitarios no cubren.
Endpoints:
- POST /api/auth/login
- POST /api/auth/refresh
- GET /api/auth/session
- POST /api/auth/callback
Herramienta: [fast-check / fuzzilli manual con Vitest / k6]
Quiero:
1. Un fuzz test que genere bodies arbitrarios y verifique que el servidor SIEMPRE
responde con un código de la lista válida (401, 400, 422). NUNCA 500.
Un 500 en un endpoint de auth es un bug de calidad Y de seguridad
2. Fuzzing del header Authorization: tokens malformados, con segmentos vacíos,
con caracteres de control, con payload de 1MB, con base64 inválido
3. Fuzzing del email: null bytes, Unicode homoglifos (аdmin@ en cirílico),
insanely long, con CRLF para intentar inyectar cabeceras
4. Fuzzing del rate limiter: 100 requests followed, ¿bloquea a los N segundos
correctos? ¿Devuelve 429 con Retry-After?
5. Que cada test tenga un timeout corto para que el fuzzing no se eternice
6. Cómo reportar un hallazgo: quiero un log que diga QUERIED fue el input que
provocó el 500, para poder reproducirlo
Nada de "añade validación". Quiero los tests con fast-check y los generadores.
Ejemplo real:
import fc from 'fast-check';
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from '../src/app.js';
const CODIGOS_VALIDOS = [400, 401, 422, 429];
describe('fuzzing POST /api/auth/login', () => {
it('nunca devuelve 500 con un body arbitrario', async () => {
await fc.assert(
fc.asyncProperty(
fc.anything(),
async (body) => {
const res = await request(app)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send(typeof body === 'string' ? body : JSON.stringify(body));
expect(CODIGOS_VALIDOS).toContain(res.status);
if (res.status === 500) {
console.error('INPUT QUE PROVOCÓ 500:', JSON.stringify(body));
}
},
),
{ numRuns: 300, timeout: 30_000 },
);
});
it('nunca devuelve 500 con un email malicioso', async () => {
await fc.assert(
fc.asyncProperty(
fc.oneof(
fc.string({ unit: 'binary', minLength: 1, maxLength: 500 }),
fc.constant('аdmin@x.com'), // 'а' cirílico, homoglifo
fc.constant('a\r\nX-Injected: true'),
fc.constant(`${'a'.repeat(10_000)}@x.com`),
),
async (email) => {
const res = await request(app)
.post('/api/auth/login')
.send({ email, password: 'x' });
expect(CODIGOS_VALIDOS).toContain(res.status);
},
),
{ numRuns: 200 },
);
});
});
10. Convertir los tests de auth en un gate de CI
Un test de seguridad que no bloquea el deploy es documentación. Este prompt convierte la batería en algo que frena el pipeline.
Tengo esta suite de seguridad de auth: [describe brevemente]
Quiero un workflow de GitHub Actions que ejecute estos tests en CADA pull request y
que BLOQUEE el merge si algo falla.
Requisitos:
1. Workflow que corra los tests de auth (unit + integración con Supertest)
2. Un linter de secretos que falle si hay JWT_SECRET, NEXTAUTH_SECRET o API keys
commiteadas en el repo (gitleaks o similar)
3. Una lista de verificación de dependencias: npm audit en modo fail, pero solo para
vulnerabilidades HIGH y CRITICAL en paquetes de auth (jsonwebtoken, bcrypt,
argon2, passport, next-auth). No quiero que un bug de otro paquete bloquee el deploy
4. Un test que verifique que la configuración de auth en el entorno de CI NO usa
secretos de desarrollo. Que alguien no pueda hacer push con el secret real
5. Los resultados como artefacto: quiero un informe HTML de cobertura de los tests
de autorización, guardado 30 días
6. El tiempo máximo de ejecución: si los tests de auth tardan más de 5 minutos,
que fallen. Un gate que tarda media hora no se mira
Nada de "considera añadir". Quiero el fichero YAML completo y los scripts de npm.
Anti-patrones
1. Un test que solo dice “401 sin token”
// ❌ Lo que genera la IA cuando le pides "tests de auth"
it('rechaza peticiones sin token', async () => {
await request(app).get('/api/invoices').expect(401);
});
// 40 endpoints con este test = 40 tests que no detectan un IDOR
// ✅ La batería por endpoint, incluyendo el caso ajeno
it('rechaza sin token', async () => { ...401 });
it('permite al propietario', async () => { ...200 });
it('bloquea a otro usuario sobre su recurso', async () => { ...403 });
it('bloquea y no modifica nada', async () => { ...403 + assert en BD });
2. Comparar contraseñas con ===
// ❌ Filtrado por timing: el tiempo de respuesta depende de cuántos caracteres
// coinciden. Medible en local con 10.000 peticiones.
if (storedHash === inputHash) { ... }
// ✅ bcrypt verifica comparando, y el coste constante del algoritmo
// enmascara la diferencia
const match = await bcrypt.compare(password, storedHash);
3. Aceptar el algoritmo que dice el propio token
// ❌ jwt.verify sin fijar algorithms acepta "alg: none" y HS256 con la
// clave pública del servidor como secreto
jwt.verify(token, process.env.JWT_SECRET);
// ✅ El algoritmo lo decide el servidor, no el atacante
jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
4. Guardar el JWT en localStorage “porque es más fácil”
// ❌ Cualquier XSS hace: localStorage.getItem('token') → exfiltrado
localStorage.setItem('token', accessToken);
// ✅ La cookie HttpOnly es inaccesible desde JavaScript
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 15 * 60 * 1000,
});
5. Logout que solo borra en el cliente
// ❌ localStorage.removeItem('token') y el JWT sigue siendo válido hasta
// que expira. Con 15 minutos de TTL tienes una ventana real de robo.
localStorage.removeItem('token');
// ✅ Denegación con lista: la sesión se invalida en el servidor
const sessionId = jwt.decode(token).sid;
await db.session.update({ where: { id: sessionId }, data: { revokedAt: new Date() } });
res.clearCookie('session', { httpOnly: true, path: '/' });
6. Probar autorización solo desde la UI
// ❌ Solo Playwright por la interfaz. Lento, frágil, y si el front hace el
// filtrado pero el backend no, el test pasa mientras la API está abierta
test('el usuario no ve la factura ajena', async ({ page }) => { ... });
// ✅ Supertest contra la API, que es donde vive la decisión real
test('la API no devuelve la factura ajena', async () => { ... });
Errores frecuentes
“Todos mis tests pasan y el escáner de seguridad encuentra IDOR.” Porque tus tests solo cubren el camino del propietario. Es el indicador claro de que el prompt 1 (la matriz) no se ejecutó. Vuelve a hacerlo.
“Los tests de expiración fallan en CI de forma intermitente.” Sleeps reales. Con un TTL de 1 segundo y una máquina cargada, el sleep de 1,1 segundos no siempre basta. Inyecta el reloj en lugar de esperar.
“El test de cookies falla en local y pasa en CI.” Secure sobre HTTP no envía la cookie. En local usa http://localhost que los navegadores tratan como seguro, o configura el transporte sin Secure solo en desarrollo.
“El rate limiter bloquea mis propios tests.” El limitador usa la IP como clave y CI ejecuta desde la misma IP. Lo más rápido: desactiva el limitador en el entorno de test con una variable de entorno, y añade un test aparte que sí lo active.
“Añado un endpoint nuevo y no me acuerdo de los tests de auth.” Es lo que arregla el gate de CI, no la buena voluntad. Pon en el middleware una aserción por defecto: todo endpoint bajo /api/ que no sea explícitamente público exige sesión. Lo que no esté en la lista de públicos, es privado por omisión.
“El 403 devuelve el recurso en el mensaje de error.” 403: la factura 123 pertenece a otro usuario ya confirma que la factura existe. Devuelve 404 cuando no quieres revelar existencia, o un mensaje genérico.
El flujo completo
┌───────────────────────────────────────────────────────────────┐
│ 0. EL MODELO DE PERMISOS, POR ESCRITO │
│ endpoints | roles | relaciones entre recursos │
│ ¿Tienes una matriz? Sin ella no hay tests que signifiquen │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 1. PROMPT DE MATRIZ → tabla de autorización completa │
│ Detecta endpoints sin dueño y candidatos a IDOR │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 2. BATERÍA ANTI-IDOR POR ENDPOINT (4-7 casos cada uno) │
│ sin token | inválido | propio | ajeno | otro tenant │
│ Y el assert de "no se modificó nada" en las mutaciones │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 3. TIEMPO Y CRONOMETERS, CON RELOJ INYECTADO │
│ expiración | revocación | rotación | coste de hash │
│ Nada de sleeps reales que duren minutos │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 4. BORDE: cookies, mass assignment, open redirect, alg:none │
│ Los fallos que un test de "login correcto" nunca ve │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 5. FUZZING → que nunca haya un 500 en auth │
│ Es el paso que encuentra lo que nadie pensó │
└───────────────────────────┬───────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 6. GATE DE CI → el pipeline falla si un test de auth falla │
│ Porque un test de seguridad que no bloquea, no existe │
└───────────────────────────┬───────────────────────────────────┘
Conclusión
El fallo caro en autenticación y autorización no es un login roto: es un endpoint que responde bien a quien no debería. Y ese fallo aparece cuando nadie escribió el test donde el usuario A intenta lo que hace el usuario B.
Tres cosas que me llevo de aquí. La matriz de autorización es lo primero, porque sin ella estás probando un sistema que no has definido. Los casos de mutación necesitan el assert de “no se modificó nada”, porque un 403 que llega después de escribir en la base de datos es un 403 inútil. Y los tiempos van inyectados, porque un test que tarda 15 minutos no existe.
Para escribir esos prompts sin pelearte con la ventana de contexto, mira cómo aprovechar el context window. Y si el punto de partida es la implementación y no los tests, empieza por proteger una API Node.js con JWT y por la implementación real de Auth.js.
Los otros dos de esta serie van al revés del que acabas de leer: primero detectar que algo se rompió, después medir cómo está el rendimiento. Está análisis de rendimiento web con IA para cuando el problema es que va lento, y detección de regresiones para cuando el problema es que algo cambió sin que nadie lo notara.
Y si al desplegar te sale un CORS que rompe el preflight del login, casi siempre es el mismo origen: configuración de CORS en Node.js y Express. Para la parte de escribir los prompts en sí, la herramienta interactiva y los mejores prompts para programar con IA son el sitio donde empezar.