teqo-backup/tests/unit/privateMedia.unit.spec.ts
Francisco Solla dda55adc27 feat(S27): Central de Conteúdos pública — Peça voto pra Solla 1313
/conteudos + /conteudos/<slug>: catálogo filtrável (Tipo · Cidade · Região ·
Tema · Instituição + termo na URL canônica), play sob demanda (Vídeo/Áudio só
carregam no toque; Foto com preview leve), Baixar e Compartilhar do próprio
card com a mensagem de voto por tipo (wa.me sem destinatário, editável; "A
mídia" usa a folha nativa e cai em download onde o aparelho não suportar) e o
card personalizado como peça sintética do catálogo → /cards.

- leitura pública nova (contentPieceReads) cacheada na tag contentPieces com o
  gate where status=publicado + contentPieceIsPublic; a rota GET
  /conteudos/<slug>/midia reusa buildPrivateMediaResponse (range, disposition,
  private no-store, 404 uniforme) com download jorge-solla-1313-<slug>.<ext> e
  a allowlist inline do dono estendida (webp/gif/avif + áudios das peças)
- contrato público puro do catálogo (filtros/VM/download) + mensagens de voto
  (contentPieceShare); OG com fallback do global; conteudos entra nos slugs
  reservados; kill switch por peça vale na hora (lista, página, rodapé/selo);
  descoberta condicional no CampaignFooter e no header local
- UI portada classe-a-classe do design aprovado (cenas 01–08, com as cenas
  06–08 estendidas no trigger a); sem migration, sem Consent e sem access novo;
  contentMedia segue privada (sem segunda cópia de mídia)
- testes: 2 unit specs novos, 18 casos int (leitura pública + rota de mídia) e
  e2e frontendConteudos (5 casos); manifest e curado atualizados
- follow-up de engenharia: #1276 (docs/plans/escala-dry-pos-s27.md)

Closes #1255
2026-09-23 11:13:57 -03:00

120 lines
4.3 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
PRIVATE_MEDIA_FALLBACK_MIME_TYPE,
privateMediaContentDisposition,
privateMediaContentType,
privateMediaHeaders,
type PrivateMediaRange,
} from '@/lib/privateMedia'
const fullRange: PrivateMediaRange = {
status: 200,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '100' },
}
const partialRange: PrivateMediaRange = {
status: 206,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '10', 'Content-Range': 'bytes 0-9/100' },
}
const invalidRange: PrivateMediaRange = {
status: 416,
headers: { 'Content-Range': 'bytes */100' },
}
describe('private media response rules (C193/C199)', () => {
it('serves only allowlisted types inline; everything else degrades', () => {
expect(privateMediaContentType('video/mp4')).toBe('video/mp4')
expect(privateMediaContentType('video/quicktime')).toBe('video/quicktime')
expect(privateMediaContentType('video/x-matroska')).toBe('video/x-matroska')
expect(privateMediaContentType('audio/mpeg')).toBe('audio/mpeg')
expect(privateMediaContentType('image/jpeg')).toBe('image/jpeg')
expect(privateMediaContentType('text/html')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(privateMediaContentType(null)).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
})
// S27 — the content pieces' photo/audio families: a published photo must
// render and an audio piece must play, so these render inline; markup
// (SVG) and formats browsers do not render (HEIC) stay download-only.
it('serves the content piece image and audio families inline', () => {
for (const mimeType of [
'image/png',
'image/webp',
'image/gif',
'image/avif',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/opus',
'audio/wav',
'audio/webm',
'audio/flac',
]) {
expect(privateMediaContentType(mimeType), mimeType).toBe(mimeType)
}
expect(privateMediaContentType('image/heic')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(privateMediaContentType('image/svg+xml')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(privateMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',
)
expect(privateMediaContentDisposition('capa ção.png', true)).toBe(
'attachment; filename="capa_o.png"; filename*=UTF-8\'\'capa%20%C3%A7%C3%A3o.png',
)
expect(privateMediaContentDisposition(null, false)).toContain('filename="arquivo"')
})
it('keeps the range headers and adds the private streaming contract', () => {
const headers = privateMediaHeaders({
range: partialRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes 0-9/100')
expect(headers.get('Content-Length')).toBe('10')
expect(headers.get('Accept-Ranges')).toBe('bytes')
expect(headers.get('Content-Type')).toBe('video/mp4')
expect(headers.get('Content-Disposition')).toContain('inline')
expect(headers.get('Cache-Control')).toBe('private, no-store')
expect(headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(headers.get('Content-Security-Policy')).toBeNull()
})
it('forces a download for unsafe types and for ?download=1', () => {
const unsafe = privateMediaHeaders({
range: fullRange,
mimeType: 'text/html',
filename: 'roteiro.html',
download: false,
})
expect(unsafe.get('Content-Type')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(unsafe.get('Content-Disposition')).toContain('attachment')
expect(unsafe.get('Content-Security-Policy')).toBe("default-src 'none'")
const explicit = privateMediaHeaders({
range: fullRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: true,
})
expect(explicit.get('Content-Disposition')).toContain('attachment')
})
it('passes the 416 headers through untouched', () => {
const headers = privateMediaHeaders({
range: invalidRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes */100')
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})