import { expect, test, type Page } from "@playwright/test"; /** * El panel comercial. Lo que más importa verificar aquí no es que las páginas * carguen, sino las dos reglas de negocio que el cliente pidió: * * 1. El panel no está expuesto en el sitio público. * 2. Un vendedor ve solo sus propios leads; el administrador ve todo. * * Una fuga en la regla 2 es una falla de seguridad, no un detalle de interfaz. */ /** Entra con el acceso rápido de demostración, eligiendo por rol. */ async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise { await page.goto("/admin/login", { waitUntil: "domcontentloaded" }); const botones = page.locator("form button, button").filter({ hasText: /.+/ }); await expect(botones.first()).toBeVisible(); // el bloque de demostración lista los perfiles con su rol como insignia const patron = rol === "admin" ? /administrador/i : /vendedor/i; const perfil = page.locator("button", { has: page.locator(`text=${rol === "admin" ? "Administrador" : "Vendedor"}`) }).first(); const objetivo = (await perfil.count()) > 0 ? perfil : page.getByRole("button").filter({ hasText: patron }).first(); const nombre = ((await objetivo.textContent()) ?? "").trim(); await objetivo.click(); await page.waitForURL(/\/admin(?!\/login)/, { timeout: 20_000 }); return nombre; } /** * Que el panel no esté enlazado es una propiedad del HTML servido, no del * navegador: se comprueba leyendo el marcado, sin abrir páginas. * * La versión anterior navegaba las cuatro rutas en un bucle y se caía sola — * `net::ERR_ABORTED` al abortar la navegación previa, y tiempos agotados al * esperar la carga completa de cada una— sin llegar nunca a evaluar lo que * pretendía. Esta versión es determinista y termina en milisegundos. */ test("el panel no se enlaza desde ninguna página pública", async ({ request }) => { for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones", "/producto/enforcer-10"]) { const res = await request.get(ruta); expect(res.status(), `${ruta} debe responder`).toBe(200); const html = await res.text(); const enlaces = [...html.matchAll(/href="(\/admin[^"]*)"/g)].map((m) => m[1]); expect( enlaces, `${ruta} enlaza al panel, y el panel no debe estar expuesto en el sitio público`, ).toEqual([]); } }); test("el panel exige sesión y devuelve a donde se quería ir", async ({ page }) => { await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/admin\/login/); expect(page.url(), "debe recordar el destino").toContain("destino"); }); test("las páginas del panel no se indexan", async ({ page }) => { await page.goto("/admin/login", { waitUntil: "domcontentloaded" }); const robots = await page.locator('meta[name="robots"]').first().getAttribute("content"); expect(robots ?? "", "el panel debe declarar noindex").toMatch(/noindex/i); }); test("el administrador entra de un clic y ve el tablero con datos", async ({ page }) => { await accesoRapido(page, "admin"); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); // el tablero tiene que traer cifras reales del seed, no ceros const cuerpo = (await page.locator("body").textContent()) ?? ""; const numeros = cuerpo.match(/\d+/g) ?? []; expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8); // En móvil la navegación del panel vive en un cajón cerrado, así que los // enlaces existen pero no están visibles. Se abre primero, que es lo que hace // cualquiera, y de paso se comprueba que el cajón funcione. const disparadorMenu = page.getByRole("button", { name: /men[úu]/i }).first(); if (await disparadorMenu.isVisible().catch(() => false)) { await disparadorMenu.click(); } // navegación completa disponible para el administrador for (const seccion of [/oportunidades/i, /contactos/i, /pedidos/i, /anal[íi]tica/i, /equipo/i, /reglas/i]) { await expect(page.getByRole("link", { name: seccion }).first(), `falta ${seccion}`).toBeVisible(); } }); test("el vendedor no ve las secciones de administración", async ({ page }) => { await accesoRapido(page, "vendedor"); await expect(page.getByRole("link", { name: /equipo/i })).toHaveCount(0); await expect(page.getByRole("link", { name: /reglas/i })).toHaveCount(0); // y si escribe la URL a mano, tampoco entra await page.goto("/admin/equipo", { waitUntil: "domcontentloaded" }); const url = page.url(); const cuerpo = (await page.locator("body").textContent()) ?? ""; const bloqueado = !url.includes("/admin/equipo") || /no tienes|sin permiso|no autorizado|404/i.test(cuerpo); expect(bloqueado, "un vendedor no debe poder abrir /admin/equipo").toBe(true); }); test("el vendedor solo ve oportunidades propias", async ({ page }) => { const nombreVendedor = await accesoRapido(page, "vendedor"); await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" }); const filas = page.locator("table tbody tr"); const total = await filas.count(); if (total === 0) { // aceptable solo si el panel lo dice con un estado vacío, no con una tabla muda await expect(page.getByText(/sin oportunidades|todav[íi]a no|no hay/i).first()).toBeVisible(); return; } // el nombre del vendedor debe salir de su propio perfil, no del listado const propio = nombreVendedor.split("\n")[0]?.trim().split(" ")[0] ?? ""; expect(propio.length, "no se pudo leer el nombre del vendedor").toBeGreaterThan(1); // ninguna fila debe pertenecer a otra persona: comparamos contra el total del admin expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200); }); /** * La propiedad que importa no es cuántos folios se ven en la primera página * —ambos listados están paginados, así que un folio del vendedor puede estar en * la página tres del administrador y la comparación no probaría nada— sino que * un vendedor **no pueda abrir** una oportunidad que no le pertenece. * * Eso es una falla de seguridad si se rompe, no un detalle de interfaz. */ test("un vendedor no puede abrir una oportunidad de otra persona", async ({ browser }) => { const ctxAdmin = await browser.newContext(); const ctxVendedor = await browser.newContext(); const pAdmin = await ctxAdmin.newPage(); const pVendedor = await ctxVendedor.newPage(); try { // el administrador reúne los identificadores de todo el universo await accesoRapido(pAdmin, "admin"); await pAdmin.goto("/admin/oportunidades?porPagina=100", { waitUntil: "domcontentloaded" }); const idsAdmin = await pAdmin.evaluate(() => Array.from(document.querySelectorAll('a[href*="/admin/oportunidades/"]')) .map((a) => a.getAttribute("href")!.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? "") .filter((v) => v.length > 6), ); expect(idsAdmin.length, "el administrador debe ver oportunidades").toBeGreaterThan(0); // el vendedor reúne las suyas await accesoRapido(pVendedor, "vendedor"); await pVendedor.goto("/admin/oportunidades?porPagina=100", { waitUntil: "domcontentloaded" }); const idsVendedor = new Set( await pVendedor.evaluate(() => Array.from(document.querySelectorAll('a[href*="/admin/oportunidades/"]')) .map((a) => a.getAttribute("href")!.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? "") .filter((v) => v.length > 6), ), ); // el alcance del vendedor tiene que ser un subconjunto propio expect( idsVendedor.size, "el vendedor no debería ver todo el universo del administrador", ).toBeLessThan(idsAdmin.length); // y una oportunidad ajena, pedida por URL directa, debe negarse const ajena = idsAdmin.find((id) => !idsVendedor.has(id)); expect(ajena, "hace falta una oportunidad ajena para probar el aislamiento").toBeTruthy(); const res = await pVendedor.goto(`/admin/oportunidades/${ajena}`, { waitUntil: "domcontentloaded", }); const cuerpo = (await pVendedor.locator("body").textContent()) ?? ""; const negado = res?.status() === 404 || !pVendedor.url().includes(ajena!) || /no encontrad|sin permiso|no autorizado|no tienes/i.test(cuerpo); expect( negado, `un vendedor abrió la oportunidad ajena ${ajena} — es una fuga de datos entre roles`, ).toBe(true); } finally { await ctxAdmin.close(); await ctxVendedor.close(); } }); test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => { await accesoRapido(page, "admin"); await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" }); // se navega por URL con un identificador real: hacer clic en "el primer // enlace que empiece con la ruta" caía en un enlace del propio listado y la // prueba terminaba comprobando la lista, no el detalle const id = await page.evaluate(() => { const a = document.querySelector('a[href*="/admin/oportunidades/"]'); return a?.getAttribute("href")?.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? null; }); expect(id, "el listado debe ofrecer al menos una oportunidad").toBeTruthy(); await page.goto(`/admin/oportunidades/${id}`, { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); const cuerpo = (await page.locator("body").textContent()) ?? ""; // los campos que justifican todo el sistema de medición de campañas for (const campo of [/source/i, /medium/i, /campaign/i]) { expect(cuerpo, `el detalle debe mostrar ${campo}`).toMatch(campo); } expect(cuerpo).toMatch(/OPP-\d{4}-\d{4}/); }); test("el historial del contacto acumula sus envíos", async ({ page }) => { await accesoRapido(page, "admin"); await page.goto("/admin/contactos", { waitUntil: "domcontentloaded" }); const id = await page.evaluate(() => { const a = document.querySelector('a[href*="/admin/contactos/"]'); return a?.getAttribute("href")?.split("/admin/contactos/")[1]?.split(/[?#]/)[0] ?? null; }); expect(id, "el listado debe ofrecer al menos un contacto").toBeTruthy(); await page.goto(`/admin/contactos/${id}`, { waitUntil: "domcontentloaded" }); const cuerpo = (await page.locator("body").textContent()) ?? ""; expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/); }); test("el panel es usable en móvil", async ({ page }) => { await page.setViewportSize({ width: 375, height: 780 }); await accesoRapido(page, "admin"); // se recarga el tablero para medir sin el desplazamiento residual que deja el // envío del formulario de acceso; es además el estado en el que cualquiera ve // el panel a partir de su segunda página await page.goto("/admin", { waitUntil: "domcontentloaded" }); await page.locator("h1").first().waitFor({ state: "visible" }); // se mide si la página se corre de lado, que es lo único que percibe la // persona; `documentElement.scrollWidth` da falsos positivos cuando hay // contenedores con scroll propio const r = await page.evaluate(() => { window.scrollTo(800, window.scrollY); const movio = window.scrollX; window.scrollTo(0, window.scrollY); return { movio, cuerpo: document.body.scrollWidth, visible: document.documentElement.clientWidth }; }); expect( r.movio > 2, `el tablero se desplaza en horizontal en móvil: se corrió ${r.movio}px (cuerpo ${r.cuerpo} sobre ${r.visible})`, ).toBe(false); }); test("cerrar sesión devuelve al acceso", async ({ page }) => { await accesoRapido(page, "admin"); await page.getByRole("button", { name: /salir|cerrar sesi[óo]n/i }).first().click(); await page.waitForURL(/\/admin\/login/, { timeout: 15_000 }); // y la sesión ya no sirve await page.goto("/admin", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/admin\/login/); });