// ===================================================================== // Verificación rápida de la API sin navegador. // node tests/api.mjs // Comprueba que cada sección responda y que los permisos se apliquen. // ===================================================================== const BASE = process.env.BASE ?? 'http://localhost:4180'; let fallos = 0; let pasos = 0; function ok(cond, etiqueta, extra = '') { pasos++; if (cond) console.log(` ✔ ${etiqueta}${extra ? ` · ${extra}` : ''}`); else { fallos++; console.log(` ✖ ${etiqueta}${extra ? ` · ${extra}` : ''}`); } } /** Sesión con su propia bolsa de cookies. */ async function entrar(email) { const r1 = await fetch(`${BASE}/api/auth/enlace`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const { token } = await r1.json(); const r2 = await fetch(`${BASE}/api/auth/consumir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const cookie = r2.headers.getSetCookie().map((c) => c.split(';')[0]).join('; '); const pedir = (ruta, opciones = {}) => fetch(`${BASE}/api${ruta}`, { ...opciones, headers: { 'Content-Type': 'application/json', cookie, ...(opciones.headers ?? {}) }, body: opciones.body ? JSON.stringify(opciones.body) : undefined, }); return { pedir, json: async (ruta) => (await pedir(ruta)).json() }; } const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora']; console.log('\n▸ Verificación de la API del Portal INSTA\n'); // ---- navegación por rol ---------------------------------------------- console.log('Navegación por rol'); const esperado = { director: 10, administrativo: 9, empleado: 8 }; const sesiones = {}; for (const [rol, email] of Object.entries({ director: 'director@insta.demo', administrativo: 'administracion@insta.demo', empleado: 'asesor@insta.demo', })) { sesiones[rol] = await entrar(email); const yo = await sesiones[rol].json('/auth/yo'); ok(yo.navegacion.length === esperado[rol], `${rol} ve ${esperado[rol]} secciones`, `vio ${yo.navegacion.length}: ${yo.navegacion.map((s) => s.id).join(', ')}`); } // ---- todas las secciones responden ---------------------------------- console.log('\nTodas las secciones responden para dirección'); for (const s of SECCIONES) { const r = await sesiones.director.pedir(`/${s}`); ok(r.ok, `GET /${s}`, r.ok ? '' : `${r.status}`); } // ---- el empleado no ve lo restringido ------------------------------- console.log('\nEl empleado no alcanza lo restringido'); for (const s of ['equipo', 'bitacora']) { const r = await sesiones.empleado.pedir(`/${s}`); ok(r.status === 403, `GET /${s} responde 403`, `respondió ${r.status}`); } console.log('\nEl empleado no puede editar nada'); const intentos = [ ['PATCH', '/oferta/1', { nombre: 'Cambio no autorizado' }], ['POST', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'https://meet.google.com/x' }], ['PATCH', '/preguntas/1', { pregunta: 'X' }], ['PATCH', '/becas/1', { nombre: 'X' }], ['PATCH', '/contacto/1', { valor: 'X' }], ['DELETE', '/requisitos/1', null], ['PATCH', '/datos/1', { estado: 'aclarado' }], ['POST', '/titulacion', { nombre: 'X' }], ['POST', '/horarios', { programa_id: 1, modalidad_id: 1, dias: 'Lunes' }], ['POST', '/mensajes', { categoria: 'X', titulo: 'X', cuerpo: 'X' }], ]; for (const [metodo, ruta, cuerpo] of intentos) { const r = await sesiones.empleado.pedir(ruta, { method: metodo, body: cuerpo ?? undefined }); ok(r.status === 403, `${metodo} ${ruta} responde 403`, `respondió ${r.status}`); } // ---- administración: solo clases ------------------------------------ console.log('\nAdministración edita clases pero no el catálogo'); const negadosParaAdmin = [ ['PATCH', '/oferta/1', { nombre: 'X' }], ['PATCH', '/becas/1', { nombre: 'X' }], ['PATCH', '/preguntas/1', { pregunta: 'X' }], ['PATCH', '/contacto/1', { valor: 'X' }], ['DELETE', '/requisitos/1', null], ['PATCH', '/datos/1', { estado: 'aclarado' }], ]; for (const [metodo, ruta, cuerpo] of negadosParaAdmin) { const r = await sesiones.administrativo.pedir(ruta, { method: metodo, body: cuerpo ?? undefined }); ok(r.status === 403, `${metodo} ${ruta} negado a administración`, `respondió ${r.status}`); } const creada = await sesiones.administrativo.pedir('/clases', { method: 'POST', body: { programa_id: 1, materia: 'Materia de prueba', docente: 'Prueba', grupo: 'Prueba', dias: 'Viernes', hora_inicio: '18:00', hora_fin: '19:30', enlace: 'https://meet.google.com/prueba-abc-def', notas: '', activo: 1, }, }); const nueva = await creada.json(); ok(creada.status === 201 && nueva.id, 'administración crea una clase', `id ${nueva.id}`); const editada = await sesiones.administrativo.pedir(`/clases/${nueva.id}`, { method: 'PATCH', body: { materia: 'Materia de prueba editada' }, }); ok(editada.ok, 'administración edita la clase'); const borrada = await sesiones.administrativo.pedir(`/clases/${nueva.id}`, { method: 'DELETE' }); ok(borrada.ok, 'administración borra la clase'); // ---- validación de campos ------------------------------------------- console.log('\nValidación de campos'); const malos = [ ['enlace no es una URL', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'no-es-una-url' }], ['enlace con esquema raro', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', enlace: 'javascript:alert(1)' }], ['día inexistente', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunon', enlace: 'https://meet.google.com/x' }], ['hora mal escrita', '/clases', { programa_id: 1, materia: 'X', dias: 'Lunes', hora_inicio: '25:99', enlace: 'https://meet.google.com/x' }], ['materia vacía', '/clases', { programa_id: 1, materia: ' ', dias: 'Lunes', enlace: 'https://meet.google.com/x' }], ['programa que no existe', '/clases', { programa_id: 9999, materia: 'X', dias: 'Lunes', enlace: 'https://meet.google.com/x' }], ]; for (const [etiqueta, ruta, cuerpo] of malos) { const r = await sesiones.director.pedir(ruta, { method: 'POST', body: cuerpo }); const d = await r.json().catch(() => ({})); ok(r.status === 400, `rechaza: ${etiqueta}`, `${r.status} ${d.mensaje ?? ''}`); } const rvoeMalo = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { rvoe_estado: 'inventado' } }); ok(rvoeMalo.status === 400, 'rechaza un estado de RVOE inventado', `${rvoeMalo.status}`); const dineroMalo = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { mensualidad_beca: 'mucho' } }); ok(dineroMalo.status === 400, 'rechaza una mensualidad que no es número', `${dineroMalo.status}`); const campoNoDeclarado = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { clave: 'otra-clave', activo: 0 } }); ok(campoNoDeclarado.status === 400, 'ignora campos no editables y avisa que no hay nada que guardar', `${campoNoDeclarado.status}`); const prog1 = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1); ok(prog1 && prog1.clave !== 'otra-clave', 'la clave del programa no se pudo cambiar'); // ---- dirección sí puede editar el catálogo -------------------------- console.log('\nDirección edita el catálogo'); const antes = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1); const r1 = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { beneficios: 'Beneficio de prueba' } }); ok(r1.ok, 'dirección edita un programa'); const despues = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 1); ok(despues.beneficios === 'Beneficio de prueba', 'el cambio se guardó'); ok(despues.actualizado_por_nombre === 'Marisol Aguilar', 'quedó registrado quién lo cambió', despues.actualizado_por_nombre ?? '(nadie)'); await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { beneficios: antes.beneficios } }); // ---- la bitácora registra ------------------------------------------ console.log('\nLa bitácora registra'); const bit = await sesiones.director.json('/bitacora?limite=120'); ok(bit.entradas.some((e) => e.accion === 'acceso.denegado' && e.resultado === 'denegado'), 'guardó los intentos sin permiso'); ok(bit.entradas.some((e) => e.accion === 'oferta.editar'), 'guardó la edición de la oferta'); ok(bit.entradas.some((e) => e.accion === 'clases.crear'), 'guardó la creación de la clase'); ok(bit.entradas.some((e) => e.accion === 'clases.borrar'), 'guardó el borrado de la clase'); // ---- el contenido es el del CSV ------------------------------------ console.log('\nEl contenido coincide con el archivo de oferta educativa'); const oferta = await sesiones.director.json('/oferta'); ok(oferta.programas.length === 8, 'hay 8 programas', `hay ${oferta.programas.length}`); const psico = oferta.programas.find((p) => p.clave === 'prog-psicologia'); ok(Number(psico.mensualidad_beca) === 1500 && Number(psico.mensualidad_lista) === 2000, 'Psicología: $2,000 de lista y $1,500 con beca'); ok(psico.rvoe_acuerdo === '20110687', 'Psicología: acuerdo 20110687'); const bach = oferta.programas.find((p) => p.clave === 'prog-bachillerato'); ok(Number(bach.inscripcion_lista) === 1125 && Number(bach.mensualidad_beca) === 1125, 'Bachillerato: inscripción $1,125 y mensualidad con beca $1,125'); ok(bach.rvoe_estado === 'por-confirmar', 'Bachillerato: RVOE sin confirmar'); // Bachillerato y las dos maestrías sin acuerdo, más Trabajo Social por revisar. ok(oferta.programas.filter((p) => p.rvoe_estado === 'por-confirmar').length === 3, '3 programas sin número de acuerdo'); ok(oferta.programas.filter((p) => p.rvoe_estado === 'revisar').length === 1, '1 programa por revisar (Trabajo Social)'); ok(oferta.programas.filter((p) => p.rvoe_estado === 'confirmado').length === 4, '4 programas con validez confirmada'); const req = await sesiones.director.json('/requisitos'); const nivelBach = req.niveles.find((n) => n.clave === 'bachillerato'); ok(nivelBach.requisitos.some((r) => r.texto.includes('6 fotografías')), 'el bachillerato pide 6 fotografías'); ok(req.niveles.find((n) => n.clave === 'licenciatura').requisitos.some((r) => r.texto.includes('4 fotografías')), 'la licenciatura pide 4 fotografías'); ok(req.titulacion.length === 5, 'hay 5 formas de titulación', `hay ${req.titulacion.length}`); const clases = await sesiones.director.json('/clases'); ok(clases.clases.length >= 27, 'hay al menos 27 clases', `hay ${clases.clases.length}`); ok(clases.clases.every((c) => /^https?:\/\//.test(c.enlace)), 'todos los links son direcciones válidas'); ok(new Set(clases.clases.map((c) => c.programa_id)).size === 8, 'hay clases en los 8 programas'); const becas = await sesiones.director.json('/becas'); ok(becas.promociones.length === 5, 'hay 5 esquemas de precio', `hay ${becas.promociones.length}`); // La fuente solo declara no acumulable el programa de referidos: la beca y los // descuentos de temporada sí se combinan, y así se ofrecen en WhatsApp. const referidos = becas.promociones.find((p) => p.clave === 'referidos'); ok(referidos && referidos.acumulable === 0, 'el programa de referidos no se combina con nada'); ok(becas.promociones.filter((p) => p.acumulable === 0).length === 1, 'es el único que no se combina'); ok(becas.promociones.find((p) => p.clave === 'beca-general').acumulable === 1, 'la beca del 25% sí se combina'); ok(becas.escalaReferidos[0].conviene === 'beca', 'con 1 referido conviene la beca'); ok(becas.escalaReferidos[4].mensualidad_licenciatura === 0, 'con 5 referidos la mensualidad queda en 0'); ok(becas.fechas.length === 8, 'la tabla de fechas trae los 8 programas'); const mensajes = await sesiones.director.json('/mensajes'); const caro = mensajes.guiones.find((g) => g.titulo.includes('está caro')); ok(caro && !/otro 20%/.test(caro.cuerpo), 'el mensaje de precio no suma referidos sobre la beca'); ok(caro && /no se juntan/i.test(caro.cuerpo), 'el mensaje de precio aclara que se elige un esquema'); const kb = await sesiones.director.json('/preguntas'); ok(kb.articulos.every((a) => !/seis licenciaturas/i.test(a.respuesta)), 'no dice "seis licenciaturas": son cinco'); // ---- el empleado solo ve clases activas ----------------------------- console.log('\nAlcance de las clases'); const clasesEmpleado = await sesiones.empleado.json('/clases'); ok(clasesEmpleado.puedeEditar === false, 'el empleado no recibe permiso de edición'); ok(clasesEmpleado.clases.every((c) => c.activo === 1), 'el empleado solo ve clases activas'); // ---- sin sesión ----------------------------------------------------- console.log('\nSin sesión'); for (const ruta of ['/inicio', '/oferta', '/clases']) { const r = await fetch(`${BASE}/api${ruta}`); ok(r.status === 401, `GET ${ruta} sin sesión responde 401`, `respondió ${r.status}`); } // ---- el enlace de acceso sirve UNA sola vez, incluso en paralelo ----- console.log('\nEl enlace de acceso'); { const r1 = await fetch(`${BASE}/api/auth/enlace`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'director@insta.demo' }), }); const { token } = await r1.json(); // Cinco canjes a la vez: solo uno debe crear sesión. const canjes = await Promise.all(Array.from({ length: 5 }, () => fetch(`${BASE}/api/auth/consumir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }))); const exitosos = canjes.filter((r) => r.ok); ok(exitosos.length === 1, 'cinco canjes simultáneos del mismo enlace crean una sola sesión', `crearon ${exitosos.length}`); const cookies = exitosos.flatMap((r) => r.headers.getSetCookie().map((c) => c.split(';')[0])); let sesionesValidas = 0; for (const cookie of cookies) { const yo = await fetch(`${BASE}/api/auth/yo`, { headers: { cookie } }); if (yo.ok) sesionesValidas++; } ok(sesionesValidas <= 1, 'no queda más de una sesión viva por enlace', `quedaron ${sesionesValidas}`); const tarde = await fetch(`${BASE}/api/auth/consumir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); ok(tarde.status === 401, 'reusarlo después también se rechaza', `respondió ${tarde.status}`); ok((await tarde.json()).error === 'ya-usado', 'y dice que ya se usó'); } // ---- una cookie mal formada no debe tumbar la petición --------------- { const r = await fetch(`${BASE}/api/oferta`, { headers: { cookie: 'insta_sesion=%E0%A4%A' } }); ok(r.status === 401, 'una cookie con % mal formado responde 401 y no cuelga', `respondió ${r.status}`); } // ---- dinero: ni vacío ni con formato roto se guardan en silencio ----- console.log('\nLos precios'); { const vacio = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { inscripcion_lista: '' } }); ok(vacio.status === 400, 'un precio vacío se rechaza en lugar de guardarse como $0', `${vacio.status}`); ok((await vacio.json()).campo === 'inscripcion_lista', 'y señala el campo'); const antes = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 2); const conFormato = await sesiones.director.pedir('/oferta/2', { method: 'PATCH', body: { mensualidad_lista: '$2,500' } }); ok(conFormato.ok, 'un precio escrito como "$2,500" se acepta'); const despuesP = (await sesiones.director.json('/oferta')).programas.find((p) => p.id === 2); ok(Number(despuesP.mensualidad_lista) === 2500, 'y se guarda como 2500, no como 2.50', String(despuesP.mensualidad_lista)); await sesiones.director.pedir('/oferta/2', { method: 'PATCH', body: { mensualidad_lista: antes.mensualidad_lista } }); const raro = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { mensualidad_beca: '1500 pesos' } }); ok(raro.status === 400, 'un precio con letras se rechaza', `${raro.status}`); const objeto = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { nombre: { a: 1 } } }); ok(objeto.status === 400, 'un objeto en un campo de texto se rechaza', `${objeto.status}`); } // ---- validez oficial: no se puede confirmar sin acuerdo -------------- { const r = await sesiones.director.pedir('/oferta/1', { method: 'PATCH', body: { rvoe_estado: 'confirmado', rvoe_acuerdo: '' } }); ok(r.status === 400, 'no deja marcar la validez como confirmada sin número de acuerdo', `${r.status}`); } // ---- una clase sin hora no se guarda -------------------------------- { const r = await sesiones.director.pedir('/clases', { method: 'POST', body: { programa_id: 1, materia: 'Sin hora', dias: 'Lunes', hora_inicio: '', hora_fin: '', enlace: 'https://meet.google.com/x-y-z' }, }); ok(r.status === 400, 'una clase sin hora se rechaza', `${r.status}`); } // ---- consultas raras no deben tumbar nada --------------------------- console.log('\nEntradas raras'); { const todo = await sesiones.director.json('/preguntas?q=%25'); const normal = await sesiones.director.json('/preguntas'); ok(todo.articulos.length < normal.articulos.length, 'buscar "%" no devuelve todo', `devolvió ${todo.articulos.length} de ${normal.articulos.length}`); const bit = await sesiones.director.pedir('/bitacora?limite=50.5'); ok(bit.ok, 'limite=50.5 no revienta la consulta', `respondió ${bit.status}`); const grande = await sesiones.director.pedir('/preguntas/1', { method: 'PATCH', body: { respuesta: 'x'.repeat(300_000) } }); ok(grande.status === 413 || grande.status === 400, 'un texto enorme responde 413 o 400, no 500', `respondió ${grande.status}`); ok((await grande.json()).mensaje, 'y con un mensaje que se pueda leer'); const malJson = await fetch(`${BASE}/api/auth/enlace`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{esto no es json', }); ok(malJson.status === 400, 'un JSON roto responde 400', `respondió ${malJson.status}`); const idRaro = await sesiones.director.pedir('/clases/abc', { method: 'DELETE' }); ok(idRaro.status === 404 || idRaro.status === 400, 'un id que no es número responde 404 o 400', `respondió ${idRaro.status}`); } // ---- cabeceras defensivas ------------------------------------------- { const r = await fetch(`${BASE}/`); const csp = r.headers.get('content-security-policy'); ok(Boolean(csp), 'se envía Content-Security-Policy'); ok(csp?.includes("default-src 'self'"), 'y no permite recursos externos'); ok(r.headers.get('x-content-type-options') === 'nosniff', 'se envía X-Content-Type-Options'); ok(r.headers.get('x-frame-options') === 'DENY', 'se envía X-Frame-Options'); } console.log(`\n${fallos === 0 ? '✔' : '✖'} ${pasos - fallos} de ${pasos} comprobaciones pasaron\n`); process.exit(fallos === 0 ? 0 : 1);