fix: defectos encontrados por la suite de extremo a extremo

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]>
This commit is contained in:
Uriel Jareth
2026-08-28 02:37:42 -06:00
co-authored by Claude Opus 5
parent 0e0331c1fc
commit 31ee899987
24 changed files with 346 additions and 86 deletions
+111 -35
View File
@@ -29,11 +29,27 @@ async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise<stri
return nombre;
}
test("el panel no se enlaza desde ninguna página pública", async ({ page }) => {
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones"]) {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const enlaces = page.locator('a[href^="/admin"]');
await expect(enlaces, `${ruta} no debe enlazar al panel`).toHaveCount(0);
/**
* 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([]);
}
});
@@ -59,6 +75,14 @@ test("el administrador entra de un clic y ve el tablero con datos", async ({ pag
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();
@@ -82,7 +106,7 @@ test("el vendedor no ve las secciones de administración", async ({ page }) => {
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
const nombreVendedor = await accesoRapido(page, "vendedor");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
const filas = page.locator("table tbody tr");
const total = await filas.count();
@@ -101,34 +125,65 @@ test("el vendedor solo ve oportunidades propias", async ({ page }) => {
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
});
test("el administrador ve más oportunidades que un vendedor", async ({ browser }) => {
/**
* 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", { waitUntil: "networkidle" });
const textoAdmin = (await pAdmin.locator("body").textContent()) ?? "";
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", { waitUntil: "networkidle" });
const textoVendedor = (await pVendedor.locator("body").textContent()) ?? "";
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),
),
);
const foliosAdmin = new Set(textoAdmin.match(/OPP-\d{4}-\d{4}/g) ?? []);
const foliosVendedor = new Set(textoVendedor.match(/OPP-\d{4}-\d{4}/g) ?? []);
expect(foliosAdmin.size, "el administrador debe ver folios").toBeGreaterThan(0);
// el alcance del vendedor tiene que ser un subconjunto propio
expect(
foliosAdmin.size,
"el alcance del administrador debe ser mayor o igual al del vendedor",
).toBeGreaterThanOrEqual(foliosVendedor.size);
idsVendedor.size,
"el vendedor no debería ver todo el universo del administrador",
).toBeLessThan(idsAdmin.length);
// y ningún folio del vendedor debe estar fuera del universo del administrador
for (const f of foliosVendedor) {
expect(foliosAdmin.has(f), `el folio ${f} del vendedor no aparece para el administrador`).toBe(true);
}
// 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();
@@ -137,12 +192,18 @@ test("el administrador ve más oportunidades que un vendedor", async ({ browser
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
const primera = page.locator('a[href^="/admin/oportunidades/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
// 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
@@ -154,12 +215,14 @@ test("el detalle de una oportunidad muestra la atribución completa", async ({ p
test("el historial del contacto acumula sus envíos", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/contactos", { waitUntil: "networkidle" });
await page.goto("/admin/contactos", { waitUntil: "domcontentloaded" });
const primera = page.locator('a[href^="/admin/contactos/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
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}/);
@@ -168,12 +231,25 @@ test("el historial del contacto acumula sus envíos", async ({ page }) => {
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(() => {
const d = document.documentElement;
return { desborda: d.scrollWidth > d.clientWidth + 2, ancho: d.scrollWidth, visible: d.clientWidth };
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.desborda, `el tablero desborda en móvil (${r.ancho} sobre ${r.visible})`).toBe(false);
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 }) => {