85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
|
|
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
|
|
|
|
export interface CsfLoginSession {
|
|
context: BrowserContext;
|
|
appPage: Page;
|
|
}
|
|
|
|
/**
|
|
* Navigates from the public CSF page → "SERVICIO" popup → FIEL login →
|
|
* returns the post-login app page (popup that became the SPA).
|
|
* Ver referencia_sat_portal_csf: el botón "Generar" vive en un iframe JSF
|
|
* dentro de esta appPage, por eso la retornamos tal cual.
|
|
*/
|
|
export async function loginSatCsf(
|
|
browser: Browser,
|
|
cerPath: string,
|
|
keyPath: string,
|
|
password: string,
|
|
): Promise<CsfLoginSession> {
|
|
const context = await browser.newContext({ acceptDownloads: true });
|
|
const publicPage = await context.newPage();
|
|
publicPage.setDefaultTimeout(60_000);
|
|
|
|
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
|
await publicPage.waitForTimeout(2000);
|
|
|
|
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
|
const obtenerLocator = publicPage.locator(
|
|
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
|
).first();
|
|
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
|
await obtenerLocator.scrollIntoViewIfNeeded();
|
|
await obtenerLocator.click();
|
|
await publicPage.waitForTimeout(1500);
|
|
|
|
// Click "SERVICIO" → popup
|
|
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
|
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
|
const loginPage = await popupPromise;
|
|
await loginPage.waitForLoadState('domcontentloaded');
|
|
loginPage.setDefaultTimeout(60_000);
|
|
|
|
// Click "e.firma" (NO "e.firma portable"). El SAT a veces aterriza en la
|
|
// pestaña de contraseña: el botón cambia a la vista FIEL. El click sintético
|
|
// de Playwright a veces no dispara el handler — afirmamos el efecto (aparece
|
|
// el file input) y reintentamos con dispatchEvent si hace falta.
|
|
const efirmaBtn = loginPage
|
|
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
|
.first();
|
|
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
|
await efirmaBtn.scrollIntoViewIfNeeded();
|
|
await efirmaBtn.click();
|
|
|
|
const fileInputs = loginPage.locator('input[type="file"]');
|
|
try {
|
|
await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 });
|
|
} catch {
|
|
// Retry: el click sintético no disparó el handler — forzamos dispatchEvent
|
|
await efirmaBtn.dispatchEvent('click');
|
|
await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 });
|
|
}
|
|
|
|
// Upload .cer (primer input) y .key (segundo)
|
|
await fileInputs.nth(0).setInputFiles(cerPath);
|
|
await fileInputs.nth(1).setInputFiles(keyPath);
|
|
|
|
// Password + Enviar
|
|
await loginPage.locator('input[type="password"]').first().fill(password);
|
|
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
|
|
|
|
// Esperar a que salga del dominio de login
|
|
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
|
|
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
|
await loginPage.waitForTimeout(2000);
|
|
|
|
const bodyText = await loginPage.locator('body').innerText().catch(() => '');
|
|
if (/contrase[nñ]a\s+incorrecta|usuario.*no.*v[aá]lido|firma\s+inv[aá]lida/i.test(bodyText)) {
|
|
throw new Error('FIEL inválida o contraseña incorrecta');
|
|
}
|
|
|
|
return { context, appPage: loginPage };
|
|
}
|