Skip to content

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étodoEndpointAuthDescripción
GET/certificatesJWT requeridoLista todos los certificados del usuario autenticado
GET/certificates/verify/:idPública (sin auth)Verifica un certificado por UUID y devuelve datos completos
POST(interno, via progress)JWT requeridoEl 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).


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


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 Elite
Y he completado todas las lecciones publicadas de una ruta (excepto la última)
Cuando marco como completada la última lección
Entonces el sistema genera automáticamente un certificado de tipo PATH_CERTIFICATE
Y el certificado tiene un hash SHA-256 único calculado en el backend
Y recibo una notificación de tipo CERTIFICATE_EARNED
Y el certificado aparece en mi portfolio en /dashboard/certificates

AC-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 ruta
Entonces NO se emite un segundo certificado
Y el certificado existente permanece inalterado

AC-CERT-001.3 — Notificación en tiempo real

Dado que acabo de completar una ruta con plan de pago
Cuando el backend persiste el certificado
Entonces aparece una notificación con título "¡Certificado obtenido!"
Y la notificación enlaza a /certificates

AC-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 FREE
Cuando completo todas las lecciones de una ruta
Entonces el campo pathCompleted es true en la respuesta
Y el campo certificateIssued es false
Y no se crea ningún registro Certificate en la BD

US: US-CERT-003

AC-CERT-003.1 — Lista certificados ordenados por fecha descendente

Dado que soy un usuario autenticado con 3 certificados obtenidos
Cuando accedo a GET /certificates
Entonces recibo un array de certificados ordenado por issuedAt descendente
Y cada certificado incluye: id, type, score, verificationHash, issuedAt, expiresAt, path, pdfUrl

AC-CERT-003.2 — Portfolio vacío muestra empty state

Dado que soy un usuario autenticado sin ningún certificado
Cuando accedo a /dashboard/certificates
Entonces veo el empty state con mensaje "Aún no tienes certificados"
Y veo un botón que enlaza a /catalog

AC-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 portfolio
Cuando visualizo la tarjeta del certificado en /dashboard/certificates
Entonces veo el título de la ruta
Y 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 integridad
Y el hash tiene exactamente 64 caracteres hexadecimales en minúsculas

AC-CERT-004.2 — Certificado sin expiración muestra “Sin expiración”

Dado que tengo un certificado con campo expiresAt null
Cuando visualizo la tarjeta
Entonces veo "Sin expiración" con color verde en la sección de vigencia

US: US-CERT-005

AC-CERT-005.1 — Descarga exitosa del PDF

Dado que estoy en mi portfolio de certificados
Cuando hago clic en "Descargar PDF" para un certificado
Entonces el botón muestra el estado "Generando..." durante la generación
Y se descarga un archivo PDF con nombre certificado_academia_{nombre}.pdf
Y 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 cliente
Cuando el PDF se genera
Entonces el hash SHA-256 impreso en el PDF es idéntico al valor devuelto por GET /certificates
Y el código de certificate-generator.ts NO calcula ningún hash — solo usa el valor de la API

AC-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álido
Cuando cualquier persona accede a GET /certificates/verify/:id sin token JWT
Entonces la API responde con status 200
Y la respuesta incluye valid: true
Y incluye: recipientName, pathTitle, score, verificationHash, issuedAt
Y el verificationHash tiene exactamente 64 caracteres hexadecimales

AC-CERT-006.2 — Página /verify/:uuid accesible sin sesión

Dado que soy un visitante sin cuenta
Cuando navego a /verify/{uuid-valido}
Entonces veo el banner "Certificado verificado" en verde
Y veo el nombre del destinatario, el título de la ruta y la fecha de emisión
Y veo el hash SHA-256 en la sección de integridad
Y puedo descargar el PDF sin necesidad de login

AC-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 respuestas

AC-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 200
Y la respuesta es { valid: false }
Y no expone ningún dato de otros certificados

AC-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 rojo
Y 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 path
Cuando se persiste el registro Certificate
Entonces el campo verificationHash fue calculado por el servidor
Con la fórmula: SHA-256(userId + pathId + issuedAt.toISOString() + score)
Y el cliente nunca recalcula ni sobreescribe este valor

AC-CERT-008.2 — certificate-generator.ts no genera hashes

Dado que el código de certificate-generator.ts genera el PDF
Cuando se renderiza el certificado en el cliente
Entonces el archivo certificate-generator.ts no importa ningún módulo de hashing
Y el valor de verificationHash que aparece en el PDF proviene exclusivamente del campo recibido de la API

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 completada
const 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 portfolio
const 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 certificado
const 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 certificado
expect(res.data.certificateIssued).toBe(false)
// El portfolio debe estar vacío
const 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 issuedAt
for (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 certificado
for (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 state
await 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 ruta
await expect(page.getByText(fixtures.pathTitle)).toBeVisible()
// Verificar que el score se muestra
await 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ón
await 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 clic
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: /Descargar PDF/i }).first().click()
const download = await downloadPromise
// Verificar nombre del archivo
expect(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ón
const 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 correcto
expect(res.data.verificationHash).toMatch(/^[a-f0-9]{64}$/)
// Verificar consistencia: el mismo hash que devuelve el portfolio autenticado
const 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 login
await 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 integridad
await expect(page.getByText(/Hash SHA-256/)).toBeVisible()
// No debe redirigir al login
await 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 certificados
expect(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 contenido
import { 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 hashing
expect(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ública
const 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}$/)