Certificados y Portfolio
Módulos API: certificates
Páginas web: CertificatesPage (/dashboard/certificates), VerifyCertificatePage (/verify/:uuid), CertificationsPage (/certifications)
Spec Playwright: apps/e2e/tests/student/certificates.spec.ts — 📋 pendiente
Módulos API cubiertos
Section titled “Módulos API cubiertos”| Método | Endpoint | Auth | Descripción |
|---|---|---|---|
GET | /certificates | JWT requerido | Lista todos los certificados del usuario autenticado |
GET | /certificates/verify/:id | Pública (sin auth) | Verifica un certificado por UUID y devuelve datos completos |
POST | (interno, via progress) | JWT requerido | El certificado se emite automáticamente al completar una ruta al 100% en progress.service.ts |
Nota sobre emisión: No hay endpoint POST /certificates/claim. La emisión es automática: cuando progress.service.ts detecta que se han completado todos los módulos de un path, genera el hash SHA-256 y persiste el Certificate en BD. Solo se emite si el UserPlanDefinition del usuario tiene features.certificates === true.
Módulo admin complementario: admin/certificates — endpoints GET /admin/certificates, GET /admin/certificates/stats, GET /admin/certificates/verify/:token, GET /admin/certificates/export (solo SUPER_ADMIN / CONTENT_ADMIN, fuera del alcance de estas user stories de estudiante).
User Stories
Section titled “User Stories”US-CERT-001: Certificado emitido automáticamente al completar un path
Section titled “US-CERT-001: Certificado emitido automáticamente al completar un path”Como estudiante con plan de pago (Starter, Pro o Elite) Quiero recibir mi certificado automáticamente cuando completo todas las lecciones de una ruta Para no tener que reclamarlo manualmente y poder compartirlo de inmediato
Criterios: AC-CERT-001 · Casos: TC-CERT-001, TC-CERT-002 · Prioridad: Alta · ✅ Implementado
US-CERT-002: No se emite certificado si el plan FREE no incluye certificados
Section titled “US-CERT-002: No se emite certificado si el plan FREE no incluye certificados”Como usuario con plan FREE que completa una ruta Quiero recibir un mensaje claro de que mi plan no incluye certificados Para conocer qué necesito para obtener uno
Criterios: AC-CERT-002 · Casos: TC-CERT-003 · Prioridad: Alta · ✅ Implementado
US-CERT-003: Ver mi portfolio de certificados
Section titled “US-CERT-003: Ver mi portfolio de certificados”Como estudiante con certificados obtenidos Quiero ver todos mis certificados en una página de portfolio Para tener una vista centralizada de mis logros
Criterios: AC-CERT-003 · Casos: TC-CERT-004, TC-CERT-005 · Prioridad: Alta · ✅ Implementado
US-CERT-004: Cada certificado muestra nombre, path, fecha, score y hash SHA-256
Section titled “US-CERT-004: Cada certificado muestra nombre, path, fecha, score y hash SHA-256”Como estudiante viendo mi portfolio Quiero que cada certificado muestre mis datos completos junto con el hash SHA-256 Para poder verificar y compartir su autenticidad
Criterios: AC-CERT-004 · Casos: TC-CERT-006 · Prioridad: Alta · ✅ Implementado
US-CERT-005: Descargar el PDF de un certificado
Section titled “US-CERT-005: Descargar el PDF de un certificado”Como estudiante con un certificado obtenido Quiero descargar el certificado en formato PDF (A4 horizontal) Para adjuntarlo en solicitudes de empleo o conservarlo offline
Criterios: AC-CERT-005 · Casos: TC-CERT-007 · Prioridad: Alta · ✅ Implementado
US-CERT-006: Verificación pública de un certificado sin autenticación
Section titled “US-CERT-006: Verificación pública de un certificado sin autenticación”Como empleador o reclutador (sin cuenta en la plataforma)
Quiero acceder a la URL /verify/:uuid y ver los datos del certificado
Para confirmar la autenticidad del certificado que un candidato me ha presentado
Criterios: AC-CERT-006 · Casos: TC-CERT-008, TC-CERT-009 · Prioridad: Alta · ✅ Implementado
US-CERT-007: UUID inválido muestra estado “no válido”
Section titled “US-CERT-007: UUID inválido muestra estado “no válido””Como visitante que accede a una URL de verificación con UUID inexistente o manipulado Quiero ver un mensaje claro de que el certificado no existe Para saber que la URL no corresponde a ningún certificado emitido
Criterios: AC-CERT-007 · Casos: TC-CERT-010 · Prioridad: Alta · ✅ Implementado
US-CERT-008: El hash SHA-256 proviene siempre del servidor
Section titled “US-CERT-008: El hash SHA-256 proviene siempre del servidor”Como auditor o revisor de seguridad Quiero verificar que el hash SHA-256 del certificado se genera exclusivamente en el backend Para garantizar que el cliente no puede falsificar ni sobrescribir el hash
Criterios: AC-CERT-008 · Casos: TC-CERT-011 · Prioridad: Crítica · ✅ Implementado
Criterios de Aceptación
Section titled “Criterios de Aceptación”AC-CERT-001: Emisión automática al completar el 100% de un path
Section titled “AC-CERT-001: Emisión automática al completar el 100% de un path”US: US-CERT-001
AC-CERT-001.1 — Certificado emitido al completar la última lección
Dado que soy un usuario con plan Starter, Pro o EliteY he completado todas las lecciones publicadas de una ruta (excepto la última)Cuando marco como completada la última lecciónEntonces el sistema genera automáticamente un certificado de tipo PATH_CERTIFICATEY el certificado tiene un hash SHA-256 único calculado en el backendY recibo una notificación de tipo CERTIFICATE_EARNEDY el certificado aparece en mi portfolio en /dashboard/certificatesAC-CERT-001.2 — No se emite certificado duplicado
Dado que ya tengo un certificado emitido para la ruta "IA para Profesionales"Cuando por cualquier motivo se vuelve a procesar la compleción de esa rutaEntonces NO se emite un segundo certificadoY el certificado existente permanece inalteradoAC-CERT-001.3 — Notificación en tiempo real
Dado que acabo de completar una ruta con plan de pagoCuando el backend persiste el certificadoEntonces aparece una notificación con título "¡Certificado obtenido!"Y la notificación enlaza a /certificatesAC-CERT-002: Plan FREE no genera certificado
Section titled “AC-CERT-002: Plan FREE no genera certificado”US: US-CERT-002
AC-CERT-002.1 — Usuario FREE completa una ruta sin certificado
Dado que soy un usuario con plan FREECuando completo todas las lecciones de una rutaEntonces el campo pathCompleted es true en la respuestaY el campo certificateIssued es falseY no se crea ningún registro Certificate en la BDAC-CERT-003: Portfolio de certificados
Section titled “AC-CERT-003: Portfolio de certificados”US: US-CERT-003
AC-CERT-003.1 — Lista certificados ordenados por fecha descendente
Dado que soy un usuario autenticado con 3 certificados obtenidosCuando accedo a GET /certificatesEntonces recibo un array de certificados ordenado por issuedAt descendenteY cada certificado incluye: id, type, score, verificationHash, issuedAt, expiresAt, path, pdfUrlAC-CERT-003.2 — Portfolio vacío muestra empty state
Dado que soy un usuario autenticado sin ningún certificadoCuando accedo a /dashboard/certificatesEntonces veo el empty state con mensaje "Aún no tienes certificados"Y veo un botón que enlaza a /catalogAC-CERT-004: Datos completos en el portfolio
Section titled “AC-CERT-004: Datos completos en el portfolio”US: US-CERT-004
AC-CERT-004.1 — Campos obligatorios visibles en la tarjeta
Dado que tengo un certificado en mi portfolioCuando visualizo la tarjeta del certificado en /dashboard/certificatesEntonces veo el título de la rutaY veo la puntuación en formato score/100 con color según rango (verde ≥80, ámbar ≥60, rojo <60)Y veo la fecha de emisión en formato "día de mes de año" (localización es-ES)Y veo el hash SHA-256 completo en la sección de integridadY el hash tiene exactamente 64 caracteres hexadecimales en minúsculasAC-CERT-004.2 — Certificado sin expiración muestra “Sin expiración”
Dado que tengo un certificado con campo expiresAt nullCuando visualizo la tarjetaEntonces veo "Sin expiración" con color verde en la sección de vigenciaAC-CERT-005: Descarga de PDF
Section titled “AC-CERT-005: Descarga de PDF”US: US-CERT-005
AC-CERT-005.1 — Descarga exitosa del PDF
Dado que estoy en mi portfolio de certificadosCuando hago clic en "Descargar PDF" para un certificadoEntonces el botón muestra el estado "Generando..." durante la generaciónY se descarga un archivo PDF con nombre certificado_academia_{nombre}.pdfY el PDF incluye el hash SHA-256 tal como lo devolvió la API (sin modificaciones)AC-CERT-005.2 — El hash en el PDF viene del servidor
Dado que el PDF se genera mediante html2canvas + jsPDF en el clienteCuando el PDF se generaEntonces el hash SHA-256 impreso en el PDF es idéntico al valor devuelto por GET /certificatesY el código de certificate-generator.ts NO calcula ningún hash — solo usa el valor de la APIAC-CERT-006: Verificación pública por UUID
Section titled “AC-CERT-006: Verificación pública por UUID”US: US-CERT-006
AC-CERT-006.1 — Acceso sin autenticación devuelve datos completos
Dado que existe un certificado con UUID válidoCuando cualquier persona accede a GET /certificates/verify/:id sin token JWTEntonces la API responde con status 200Y la respuesta incluye valid: trueY incluye: recipientName, pathTitle, score, verificationHash, issuedAtY el verificationHash tiene exactamente 64 caracteres hexadecimalesAC-CERT-006.2 — Página /verify/:uuid accesible sin sesión
Dado que soy un visitante sin cuentaCuando navego a /verify/{uuid-valido}Entonces veo el banner "Certificado verificado" en verdeY veo el nombre del destinatario, el título de la ruta y la fecha de emisiónY veo el hash SHA-256 en la sección de integridadY puedo descargar el PDF sin necesidad de loginAC-CERT-006.3 — Hash consistente entre portfolio y verificación pública
Dado que consulto un certificado en GET /certificates (autenticado)Y consulto el mismo certificado en GET /certificates/verify/:id (público)Entonces el campo verificationHash es idéntico en ambas respuestasAC-CERT-007: UUID inválido en verificación pública
Section titled “AC-CERT-007: UUID inválido en verificación pública”US: US-CERT-007
AC-CERT-007.1 — UUID inexistente devuelve valid: false
Dado que accedo a GET /certificates/verify/{uuid-inexistente}Entonces la API responde con status 200Y la respuesta es { valid: false }Y no expone ningún dato de otros certificadosAC-CERT-007.2 — Página muestra estado no válido
Dado que navego a /verify/{uuid-inexistente}Entonces veo el banner "Certificado no encontrado" en rojoY veo el mensaje "Este UUID no corresponde a ningún certificado emitido por AcademIA"Y veo el botón "Explorar rutas certificadas"AC-CERT-008: SHA-256 generado exclusivamente en el backend
Section titled “AC-CERT-008: SHA-256 generado exclusivamente en el backend”US: US-CERT-008
AC-CERT-008.1 — El hash se calcula en progress.service.ts
Dado que el sistema emite un certificado al completar un pathCuando se persiste el registro CertificateEntonces el campo verificationHash fue calculado por el servidorCon la fórmula: SHA-256(userId + pathId + issuedAt.toISOString() + score)Y el cliente nunca recalcula ni sobreescribe este valorAC-CERT-008.2 — certificate-generator.ts no genera hashes
Dado que el código de certificate-generator.ts genera el PDFCuando se renderiza el certificado en el clienteEntonces el archivo certificate-generator.ts no importa ningún módulo de hashingY el valor de verificationHash que aparece en el PDF proviene exclusivamente del campo recibido de la APITest Cases
Section titled “Test Cases”TC-CERT-001: Certificado emitido al completar la última lección
Section titled “TC-CERT-001: Certificado emitido al completar la última lección”AC: AC-CERT-001.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.pro es un usuario con plan Pro y tiene progreso en un path con una sola lección pendiente. La BD de test tiene ese path con certificate definido.
// Marcar la última lección como completadaconst res = await apiClient.post( `/progress/lessons/${fixtures.lastLessonId}/complete`, {}, { headers: { Authorization: `Bearer ${fixtures.pro.token}` } })expect(res.status).toBe(201)expect(res.data.pathCompleted).toBe(true)expect(res.data.certificateIssued).toBe(true)
// Verificar que el certificado existe en el portfolioconst certs = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.pro.token}` },})expect(certs.status).toBe(200)expect(certs.data).toHaveLength(1)expect(certs.data[0].type).toBe('PATH_CERTIFICATE')expect(certs.data[0].verificationHash).toMatch(/^[a-f0-9]{64}$/)TC-CERT-002: No se emite certificado duplicado
Section titled “TC-CERT-002: No se emite certificado duplicado”AC: AC-CERT-001.2 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.pro ya tiene un certificado para el path de test.
// Forzar re-procesamiento (simular segunda finalización)await apiClient.post( `/progress/lessons/${fixtures.lastLessonId}/complete`, {}, { headers: { Authorization: `Bearer ${fixtures.pro.token}` } })
// Verificar que sigue habiendo exactamente un certificadoconst certs = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.pro.token}` },})expect(certs.data.filter((c: any) => c.path?.id === fixtures.pathId)).toHaveLength(1)TC-CERT-003: Plan FREE no recibe certificado al completar un path
Section titled “TC-CERT-003: Plan FREE no recibe certificado al completar un path”AC: AC-CERT-002.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.free es un usuario con rol FREE que tiene una lección pendiente en un path.
const res = await apiClient.post( `/progress/lessons/${fixtures.lastLessonId}/complete`, {}, { headers: { Authorization: `Bearer ${fixtures.free.token}` } })expect(res.status).toBe(201)// El path puede completarse pero NO se emite certificadoexpect(res.data.certificateIssued).toBe(false)
// El portfolio debe estar vacíoconst certs = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.free.token}` },})expect(certs.data).toHaveLength(0)TC-CERT-004: GET /certificates devuelve portfolio ordenado
Section titled “TC-CERT-004: GET /certificates devuelve portfolio ordenado”AC: AC-CERT-003.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.pro tiene al menos 2 certificados con fechas distintas.
const res = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.pro.token}` },})expect(res.status).toBe(200)expect(Array.isArray(res.data)).toBe(true)
// Verificar orden descendente por issuedAtfor (let i = 0; i < res.data.length - 1; i++) { const a = new Date(res.data[i].issuedAt).getTime() const b = new Date(res.data[i + 1].issuedAt).getTime() expect(a).toBeGreaterThanOrEqual(b)}
// Verificar campos obligatorios en cada certificadofor (const cert of res.data) { expect(cert).toHaveProperty('id') expect(cert).toHaveProperty('type', 'PATH_CERTIFICATE') expect(cert).toHaveProperty('score') expect(cert).toHaveProperty('verificationHash') expect(cert).toHaveProperty('issuedAt') expect(cert).toHaveProperty('path') expect(cert.verificationHash).toMatch(/^[a-f0-9]{64}$/)}TC-CERT-005: Portfolio vacío muestra empty state
Section titled “TC-CERT-005: Portfolio vacío muestra empty state”AC: AC-CERT-003.2 · Prioridad: Media · Tipo: E2E · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.free no tiene ningún certificado.
await loginAs(page, fixtures.free)await page.goto('/dashboard/certificates')
// Verificar empty stateawait expect(page.getByText('Aún no tienes certificados')).toBeVisible()await expect(page.getByRole('link', { name: /Explorar rutas/i })).toHaveAttribute('href', '/catalog')TC-CERT-006: Tarjeta del certificado muestra todos los campos
Section titled “TC-CERT-006: Tarjeta del certificado muestra todos los campos”AC: AC-CERT-004.1 · Prioridad: Alta · Tipo: E2E · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.pro tiene exactamente un certificado con score 85.
await loginAs(page, fixtures.pro)await page.goto('/dashboard/certificates')
// Verificar que aparece el título de la rutaawait expect(page.getByText(fixtures.pathTitle)).toBeVisible()
// Verificar que el score se muestraawait expect(page.getByText('85/100')).toBeVisible()
// Verificar que aparece el hash SHA-256 (64 chars hex)const hashEl = page.locator('text=/SHA-256 · [a-f0-9]{64}/')await expect(hashEl).toBeVisible()
// Verificar que hay botón de descarga y enlace de verificaciónawait expect(page.getByRole('button', { name: /Descargar PDF/i })).toBeVisible()await expect(page.getByRole('link', { name: /Verificar/i })).toBeVisible()TC-CERT-007: Descarga del PDF del certificado
Section titled “TC-CERT-007: Descarga del PDF del certificado”AC: AC-CERT-005.1, AC-CERT-005.2 · Prioridad: Alta · Tipo: E2E · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: fixtures.pro tiene un certificado. El navegador permite descargas.
await loginAs(page, fixtures.pro)await page.goto('/dashboard/certificates')
// Esperar la descarga al hacer clicconst downloadPromise = page.waitForEvent('download')await page.getByRole('button', { name: /Descargar PDF/i }).first().click()const download = await downloadPromise
// Verificar nombre del archivoexpect(download.suggestedFilename()).toMatch(/^certificado_academia_.*\.pdf$/)TC-CERT-008: Verificación pública por UUID — sin autenticación
Section titled “TC-CERT-008: Verificación pública por UUID — sin autenticación”AC: AC-CERT-006.1 · Prioridad: Crítica · Tipo: API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: Existe un certificado con UUID conocido (fixtures.certificateUuid).
// Verificación pública — sin token de autenticaciónconst res = await apiClient.get(`/certificates/verify/${fixtures.certificateUuid}`)expect(res.status).toBe(200)expect(res.data.valid).toBe(true)expect(res.data.recipientName).toBeTruthy()expect(res.data.pathTitle).toBeTruthy()expect(res.data.issuedAt).toBeTruthy()
// El hash viene del servidor y tiene el formato SHA-256 correctoexpect(res.data.verificationHash).toMatch(/^[a-f0-9]{64}$/)
// Verificar consistencia: el mismo hash que devuelve el portfolio autenticadoconst portfolioRes = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.pro.token}` },})const portfolioCert = portfolioRes.data.find((c: any) => c.id === fixtures.certificateUuid)expect(res.data.verificationHash).toBe(portfolioCert.verificationHash)TC-CERT-009: Página /verify/:uuid accesible sin sesión activa
Section titled “TC-CERT-009: Página /verify/:uuid accesible sin sesión activa”AC: AC-CERT-006.2 · Prioridad: Alta · Tipo: E2E · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: Existe un certificado con UUID conocido. El contexto de navegación no tiene sesión.
// Contexto sin sesión (nueva página sin cookies de auth)const context = await browser.newContext()const page = await context.newPage()
await page.goto(`/verify/${fixtures.certificateUuid}`)
// Debe mostrar estado válido sin requerir loginawait expect(page.getByText('Certificado verificado')).toBeVisible()await expect(page.getByText(fixtures.recipientName)).toBeVisible()await expect(page.getByText(fixtures.pathTitle)).toBeVisible()
// Hash visible en la sección de integridadawait expect(page.getByText(/Hash SHA-256/)).toBeVisible()
// No debe redirigir al loginawait expect(page).not.toHaveURL(/\/login/)
await context.close()TC-CERT-010: UUID inválido en verificación pública
Section titled “TC-CERT-010: UUID inválido en verificación pública”AC: AC-CERT-007.1, AC-CERT-007.2 · Prioridad: Alta · Tipo: API + E2E · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Precondiciones: El UUID 00000000-0000-0000-0000-000000000000 no existe en BD.
// --- API: válida que devuelve valid: false ---const res = await apiClient.get('/certificates/verify/00000000-0000-0000-0000-000000000000')expect(res.status).toBe(200)expect(res.data).toEqual({ valid: false })// No filtra ningún dato de otros certificadosexpect(res.data.verificationHash).toBeUndefined()expect(res.data.recipientName).toBeUndefined()
// --- E2E: la página muestra el banner rojo ---const context = await browser.newContext()const page = await context.newPage()await page.goto('/verify/00000000-0000-0000-0000-000000000000')await expect(page.getByText('Certificado no encontrado')).toBeVisible()await expect(page.getByText(/Este UUID no corresponde/)).toBeVisible()await context.close()TC-CERT-011: Seguridad — hash SHA-256 generado exclusivamente en el backend
Section titled “TC-CERT-011: Seguridad — hash SHA-256 generado exclusivamente en el backend”AC: AC-CERT-008.1, AC-CERT-008.2 · Prioridad: Crítica · Tipo: Estático + API · Estado: 📋 Pendiente
Spec: apps/e2e/tests/student/certificates.spec.ts
Descripción: Verifica que certificate-generator.ts no importa ni llama a ningún módulo de hashing, y que el hash que la API persiste coincide con la fórmula del servidor.
// --- Verificación estática: certificate-generator.ts no genera hashes ---// Este test debe correr en el CI con una simple inspección de contenidoimport { readFileSync } from 'fs'import { resolve } from 'path'
const generatorSource = readFileSync( resolve(__dirname, '../../../../apps/web/src/lib/certificate-generator.ts'), 'utf-8')// El generador de PDF no debe contener lógica de hashingexpect(generatorSource).not.toMatch(/createHash|sha256|crypto\./)expect(generatorSource).not.toMatch(/import.*crypto/)
// --- Verificación de integridad: el hash del servidor es inmutable ---// Obtener certificado del portfolio (hash original del servidor)const portfolioRes = await apiClient.get('/certificates', { headers: { Authorization: `Bearer ${fixtures.pro.token}` },})const originalHash = portfolioRes.data[0].verificationHash
// El mismo hash debe aparecer en la verificación públicaconst verifyRes = await apiClient.get(`/certificates/verify/${portfolioRes.data[0].id}`)expect(verifyRes.data.verificationHash).toBe(originalHash)
// El hash tiene el formato SHA-256 (64 hex lowercase)expect(originalHash).toMatch(/^[a-f0-9]{64}$/)