Files
insta-portal/tests/api.mjs
T
Uriel JarethandClaude Opus 5 0b9c9c1dc2 Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol
Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 23:20:34 -06:00

329 lines
18 KiB
JavaScript

// =====================================================================
// 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: '[email protected]',
administrativo: '[email protected]',
empleado: '[email protected]',
})) {
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: '[email protected]' }),
});
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);