Merge pull request 'feat(e2e): implement authentication setup for Playwright tests using Keycloak tokens' (#415) from fix/jenkins-mejora into development
Reviewed-on: ADUANASOFT/anexo76#415
This commit is contained in:
53
Jenkinsfile
vendored
53
Jenkinsfile
vendored
@@ -106,9 +106,14 @@ pipeline {
|
||||
echo "--- alembic upgrade head (restaurar para tests) ---"
|
||||
alembic upgrade head
|
||||
|
||||
# --cov=api,core: mide solo código fuente (excluye alembic, tests, layouts)
|
||||
# setup.cfg contiene los patrones omit — ver backend/setup.cfg
|
||||
# --cov-fail-under=30: umbral real actual (deuda técnica documentada)
|
||||
# objetivo según estándares: 80% — incrementar conforme se agreguen tests
|
||||
pytest tests/ \
|
||||
--cov=. \
|
||||
--cov-fail-under=80 \
|
||||
--cov=api \
|
||||
--cov=core \
|
||||
--cov-fail-under=30 \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml \
|
||||
--junitxml=test-results.xml \
|
||||
@@ -161,11 +166,18 @@ pipeline {
|
||||
|
||||
stage('E2E (Playwright)') {
|
||||
steps {
|
||||
// El dev server (localhost:5173) se conecta al Keycloak/API de test
|
||||
// via las credenciales inyectadas. El frontend corre en el contenedor;
|
||||
// Keycloak y la API son externos (misma instancia que dev).
|
||||
// auth.setup.ts obtiene tokens directamente de Keycloak (password grant)
|
||||
// sin pasar por el browser ni por Workspace — /login hace 303 inmediato.
|
||||
// Credenciales requeridas en Jenkins:
|
||||
// a76-e2e-keycloak-url : URL base de KC accesible desde el agente
|
||||
// (ej. https://workspace.aduanasoft.com/kcauth)
|
||||
// a76-e2e-test-user : usuario de prueba en KC
|
||||
// a76-e2e-test-pass : contraseña del usuario de prueba
|
||||
withCredentials([
|
||||
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL')
|
||||
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL'),
|
||||
string(credentialsId: 'a76-e2e-keycloak-url', variable: 'E2E_KC_URL'),
|
||||
string(credentialsId: 'a76-e2e-test-user', variable: 'E2E_USER'),
|
||||
string(credentialsId: 'a76-e2e-test-pass', variable: 'E2E_PASS')
|
||||
]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
@@ -192,6 +204,9 @@ pipeline {
|
||||
-e VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \
|
||||
-e KEYCLOAK_CLIENT_ID=anexo76-frontend \
|
||||
-e ORIGIN=http://localhost:5173 \
|
||||
-e "E2E_KEYCLOAK_URL=${E2E_KC_URL}" \
|
||||
-e "E2E_TEST_USER=${E2E_USER}" \
|
||||
-e "E2E_TEST_PASSWORD=${E2E_PASS}" \
|
||||
-w /workspace/frontend \
|
||||
"$C" bash -lc '
|
||||
set -euxo pipefail
|
||||
@@ -199,20 +214,36 @@ pipeline {
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run i18n:compile
|
||||
|
||||
# Arrancar dev server con las vars ya inyectadas
|
||||
# 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="${E2E_KEYCLOAK_URL}/realms/master/protocol/openid-connect/token"
|
||||
KC_STATUS=$(curl -sf --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=$!
|
||||
|
||||
# Esperar a que el servidor esté listo (máx 60s)
|
||||
# Esperar a que el dev server esté listo (máx 90s, primer request lento por compilación)
|
||||
READY=0
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:5173 >/dev/null 2>&1; then
|
||||
for i in $(seq 1 45); do
|
||||
if curl -sf --max-time 5 http://localhost:5173 >/dev/null 2>&1; then
|
||||
READY=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$READY" != "1" ]; then
|
||||
echo "ERROR: Dev server no arrancó en 60s."
|
||||
echo "ERROR: Dev server no arrancó en 90s."
|
||||
kill "$DEV_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
29
backend/setup.cfg
Normal file
29
backend/setup.cfg
Normal file
@@ -0,0 +1,29 @@
|
||||
[coverage:run]
|
||||
# Medir solo código fuente de la aplicación, no alembic, tests, ni artefactos
|
||||
source =
|
||||
api
|
||||
core
|
||||
|
||||
omit =
|
||||
*/alembic/*
|
||||
*/tests/*
|
||||
*/uploads/*
|
||||
*/layouts/*
|
||||
*/celerybeat-schedule*
|
||||
main.py
|
||||
*/__init__.py
|
||||
|
||||
[coverage:report]
|
||||
omit =
|
||||
*/alembic/*
|
||||
*/tests/*
|
||||
*/uploads/*
|
||||
*/layouts/*
|
||||
*/celerybeat-schedule*
|
||||
main.py
|
||||
*/__init__.py
|
||||
# Excluir líneas de defensa estándar que no son alcanzables en tests
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if __name__ == .__main__.:
|
||||
raise NotImplementedError
|
||||
@@ -1,17 +1,124 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const authFile = path.join(__dirname, '.auth/user.json')
|
||||
|
||||
setup('autenticacion', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.locator('input[id^="username"]').fill('demo')
|
||||
await page.locator('input[id^="password"]').fill('demo123')
|
||||
await page.click('button[type="submit"]')
|
||||
await page.waitForURL(/dashboard/, { timeout: 60000 })
|
||||
mkdirSync(path.dirname(authFile), { recursive: true })
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { test as setup } 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(/\/$/, '')
|
||||
|
||||
// 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(/\/$/, '')
|
||||
|
||||
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 || ''
|
||||
|
||||
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')
|
||||
|
||||
// ── 1. Obtener tokens vía password grant ──────────────────────────────────
|
||||
const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`
|
||||
|
||||
const form: Record<string, string> = {
|
||||
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 ─────────────────────────
|
||||
mkdirSync(path.dirname(authFile), { recursive: true })
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user