diff --git a/Jenkinsfile b/Jenkinsfile index d86812ee..65995822 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -169,11 +169,12 @@ pipeline { stage('E2E (Playwright)') { steps { - // auth.setup.ts obtiene tokens directamente de Keycloak (password grant) - // sin pasar por el browser ni por Workspace — /login hace 303 inmediato. + // auth.setup.ts navega a /login → Workspace → llena form → /dashboard + // y guarda storageState para los tests dependientes. + // // Credenciales requeridas en Jenkins: - // a76-public-url-dev : URL pública base — la URL de KC se deriva de ella ({url}/kcauth) - // a76-e2e-credentials : Username with password — cuenta de Workspace para pruebas E2E + // a76-public-url-dev : URL pública base de Anexo76 + // a76-e2e-credentials : Username with password — usuario de prueba en Workspace withCredentials([ string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL'), usernamePassword( @@ -200,15 +201,14 @@ pipeline { -e PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 \ -e "VITE_API_URL=${A76_URL}/api/" \ -e "INTERNAL_API_URL=${A76_URL}/api/" \ - -e "VITE_KEYCLOAK_URL=${A76_URL}/kcauth" \ - -e "KEYCLOAK_URL=${A76_URL}/kcauth" \ + -e "VITE_KEYCLOAK_URL=${KC_URL}" \ + -e "KEYCLOAK_URL=${KC_URL}" \ -e VITE_KEYCLOAK_REALM=master \ -e KEYCLOAK_REALM=master \ - -e VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \ - -e KEYCLOAK_CLIENT_ID=anexo76-frontend \ + -e VITE_KEYCLOAK_CLIENT_ID=hub-frontend \ + -e KEYCLOAK_CLIENT_ID=hub-frontend \ -e ORIGIN=http://localhost:5173 \ -e "KC_URL=${KC_URL}" \ - -e "E2E_KEYCLOAK_URL=${KC_URL}" \ -e "E2E_TEST_USER=${E2E_USER}" \ -e "E2E_TEST_PASSWORD=${E2E_PASS}" \ -w /workspace/frontend \ @@ -218,24 +218,6 @@ pipeline { pnpm install --frozen-lockfile pnpm run i18n:compile - # Verificar que Keycloak acepta el password grant - # (auth.setup.ts lo usa directamente — sin pasar por /login) - echo "--- Verificando Keycloak token endpoint ---" - KC_TOKEN_EP="${KC_URL}/realms/master/protocol/openid-connect/token" - # -s sin -f: curl no falla en HTTP 4xx/5xx, devuelve el código real - # || echo "000" solo se activa si curl no puede conectar (timeout, DNS, etc.) - KC_STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" \ - -X POST "$KC_TOKEN_EP" \ - -d "grant_type=password&client_id=anexo76-frontend&username=${E2E_TEST_USER}&password=${E2E_TEST_PASSWORD}&scope=openid" \ - 2>/dev/null || echo "000") - echo "Keycloak token endpoint → HTTP ${KC_STATUS}" - if [ "$KC_STATUS" != "200" ]; then - echo "ERROR: Keycloak no accesible o password grant no habilitado (HTTP ${KC_STATUS})." - echo "URL: ${KC_TOKEN_EP}" - exit 1 - fi - echo "Keycloak OK" - # Arrancar dev server pnpm run dev & DEV_PID=$! diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts index a744d81f..57ee088e 100644 --- a/frontend/e2e/auth.setup.ts +++ b/frontend/e2e/auth.setup.ts @@ -1,124 +1,42 @@ /** * Setup de autenticación para tests E2E de Playwright. * - * El flujo de login de Anexo76 pasa por Workspace externo (workspace.aduanasoft.com) - * y Keycloak SSO — el browser nunca renderiza un form propio. Por eso NO se puede - * automatizar navegando a /login: ese route hace 303 inmediato al Workspace. + * El flujo de login de Anexo76 pasa por Workspace (workspace.aduanasoft.com): + * 1. Navegar a /login → 303 redirect al formulario de Workspace + * 2. Llenar el form de Workspace con el usuario de prueba (creado en globalSetup) + * 3. Workspace/Keycloak redirige de vuelta a /auth/callback → /dashboard + * 4. Guardar storageState para todos los tests dependientes * - * Estrategia: obtener tokens directamente del token endpoint de Keycloak - * (Resource Owner Password Credentials grant) e inyectarlos como cookies en el - * contexto de Playwright, replicando exactamente lo que hace /auth/callback. - * - * Requiere en Keycloak: cliente con "Direct Access Grants" habilitado. - * Requiere en Jenkins: credenciales a76-e2e-keycloak-url, a76-e2e-test-user, a76-e2e-test-pass. + * El usuario de prueba es creado y eliminado por globalSetup / globalTeardown. + * Patrón idéntico al de aduanasoft-hub. */ -import { test as setup } from '@playwright/test' +import { test as setup, expect } from '@playwright/test' import { mkdirSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -// Lógica de chunking idéntica a access-token-cookie.shared.ts -const ACCESS_TOKEN_MAX_SINGLE = 2800 -const ACCESS_TOKEN_CHUNK_SIZE = 2800 -const ACCESS_TOKEN_CHUNK_COUNT = 'access_token_chunks' -const chunkName = (i: number) => `access_token_${i}` - const __dirname = path.dirname(fileURLToPath(import.meta.url)) const authFile = path.join(__dirname, '.auth/user.json') -setup('autenticacion', async ({ page, request }) => { - const baseUrl = (process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:5173').replace(/\/$/, '') +setup('autenticacion', async ({ page }) => { + const username = process.env.E2E_TEST_USER ?? '' + const password = process.env.E2E_TEST_PASSWORD ?? '' - // E2E_KEYCLOAK_URL: URL base de Keycloak accesible desde el agente Jenkins. - // Puede diferir de VITE_KEYCLOAK_URL si ese apunta al dominio del propio servidor - // y el contenedor Docker no puede resolver ese dominio (hairpin NAT). - const keycloakUrl = ( - process.env.E2E_KEYCLOAK_URL || - process.env.KEYCLOAK_URL || - process.env.VITE_KEYCLOAK_URL || - '' - ).replace(/\/$/, '') + if (!username) throw new Error('E2E_TEST_USER no está configurado') + if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado') - const realm = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master' - const clientId = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-frontend' - const secret = process.env.KEYCLOAK_CLIENT_SECRET || '' - const username = process.env.E2E_TEST_USER || '' - const password = process.env.E2E_TEST_PASSWORD || '' + // ── 1. Navegar a /login — Workspace intercepta y muestra su formulario ── + await page.goto('/login') - if (!keycloakUrl) throw new Error('E2E_KEYCLOAK_URL / KEYCLOAK_URL no está configurado') - if (!username) throw new Error('E2E_TEST_USER no está configurado') - if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado') + // ── 2. Llenar el formulario de Workspace (mismos selectores que hub/login.spec.ts) ── + await page.getByRole('textbox', { name: /usuario o email/i }).fill(username) + await page.getByLabel(/contraseña/i).fill(password) + await page.getByRole('button', { name: /continuar/i }).click() - // ── 1. Obtener tokens vía password grant ────────────────────────────────── - const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token` + // ── 3. Esperar redirect de vuelta a Anexo76 (/auth/callback → /dashboard) ── + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 }) - const form: Record = { - grant_type: 'password', - client_id: clientId, - username, - password, - scope: 'openid' - } - if (secret) form.client_secret = secret - - const tokenRes = await request.post(tokenEndpoint, { form }) - - if (!tokenRes.ok()) { - const body = await tokenRes.text() - throw new Error( - `Keycloak password grant falló (HTTP ${tokenRes.status()}).\n` + - `Endpoint: ${tokenEndpoint}\n` + - `Respuesta: ${body}\n` + - `Verifica que el cliente "${clientId}" tenga "Direct Access Grants" habilitado en Keycloak.` - ) - } - - const tokens = await tokenRes.json() as { - access_token: string - refresh_token?: string - id_token?: string - } - - // ── 2. Navegar al app para establecer origen de cookies ─────────────────── - await page.goto(baseUrl) - - const hostname = new URL(baseUrl).hostname - const secure = baseUrl.startsWith('https') - const base = { domain: hostname, path: '/', sameSite: 'Lax' as const, secure } - - // ── 3. Inyectar access_token respetando chunking (igual que setAccessTokenCookies) ── - if (tokens.access_token.length <= ACCESS_TOKEN_MAX_SINGLE) { - await page.context().addCookies([ - { ...base, httpOnly: false, name: 'access_token', value: tokens.access_token } - ]) - } else { - const parts: string[] = [] - for (let i = 0; i < tokens.access_token.length; i += ACCESS_TOKEN_CHUNK_SIZE) { - parts.push(tokens.access_token.slice(i, i + ACCESS_TOKEN_CHUNK_SIZE)) - } - await page.context().addCookies([ - { ...base, httpOnly: false, name: ACCESS_TOKEN_CHUNK_COUNT, value: String(parts.length) }, - ...parts.map((p, i) => ({ ...base, httpOnly: false, name: chunkName(i), value: p })) - ]) - } - - // ── 4. Cookies httpOnly (el servidor las gestiona; Playwright puede setearlas en test) ── - if (tokens.refresh_token) { - await page.context().addCookies([ - { ...base, httpOnly: true, name: 'refresh_token', value: tokens.refresh_token } - ]) - } - if (tokens.id_token) { - await page.context().addCookies([ - { ...base, httpOnly: true, name: 'id_token', value: tokens.id_token } - ]) - } - - // ── 5. Verificar que la sesión funciona navegando al dashboard ──────────── - await page.goto(`${baseUrl}/dashboard`) - await page.waitForURL(/dashboard/, { timeout: 30000 }) - - // ── 6. Guardar estado para los tests dependientes ───────────────────────── + // ── 4. Guardar estado para los tests dependientes ───────────────────────── mkdirSync(path.dirname(authFile), { recursive: true }) await page.context().storageState({ path: authFile }) }) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 065611dd..22d6b4a5 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -11,10 +11,9 @@ const inCI = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL); export default defineConfig({ // En CI, falla si queda un .only; en local no forbidOnly: inCI, - timeout: 60000, + timeout: 60_000, use: { baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173', - // Sin display en agentes de integración: obligatorio headless headless: inCI ? true : false }, workers: 1, @@ -23,7 +22,6 @@ export default defineConfig({ { name: 'setup', testMatch: '**/auth.setup.ts', - // Navegador limpio: user.json se genera aquí y no debe existir al primer run / en CI use: { storageState: { cookies: [], origins: [] } } }, { @@ -33,4 +31,4 @@ export default defineConfig({ use: { storageState: authStatePath } } ] -}); \ No newline at end of file +});