La primera corrida completa dio 57 fallas. Solo una parte eran del producto; el resto era instrumentación mal escrita. Queda en 261 en verde. Defectos del producto: - La atribución de campaña se perdía. La captura vivía en un efecto de React, así que si la persona llegaba con parámetros de campaña y navegaba antes de hidratar, el primer toque nunca se guardaba y utmCampaign llegaba vacío al CRM. Ahora se captura en un script en línea que corre al analizar el documento. - Siete páginas sin og:image: una página que declara su propio openGraph no hereda las imágenes del layout, las sobreescribe. Solo se nota al compartir el enlace. Se resuelve con una función compartida en seo.ts. - El panel no se podía usar desde Safari por HTTP: la cookie Secure no se guarda y el acceso falla en silencio. Queda explícito y documentado. - El panel se corría de lado en móvil: la cabecera no cabía a 375px. - Las tarjetas de perfil del acceso se cortaban en móvil. Un elemento de rejilla nace con min-width auto y no puede encogerse bajo su contenido. Era además el origen de un desplazamiento que se arrastraba al tablero. - Las tablas accesibles de las gráficas volvían desplazable la página: sr-only fija width 1px y el algoritmo de tablas lo ignora. - El campo de búsqueda del catálogo no tenía nombre accesible. - Límite de tasa de 5 envíos por IP: bloqueaba una demostración en vivo con varias personas en la misma red. Configurable, ahora 20. - El salto de contenido pasa de left:-9999px a recorte de 1px. Instrumentación corregida, con la razón escrita en cada caso: - El desborde horizontal se mide intentando desplazar la página, no con documentElement.scrollWidth, que sobreinforma con contenedores propios. - La comprobación de accesibilidad respeta el árbol de accesibilidad. - El aislamiento entre roles se prueba pidiendo por URL una oportunidad ajena, no comparando folios entre listados paginados. - Fuera las esperas por networkidle, que agotaban el tiempo sin fallar. - Que el panel no esté enlazado se comprueba leyendo el HTML servido. - Safari no enfoca enlaces con Tab: declarado como del navegador. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
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<string> {
|
|
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/);
|
|
});
|