Pular para conteúdo

0010 — O anexo grande não sobe, e o que subiu quebrado abre em branco

Status CORRIGIDO
Branch fix/0010-preview-de-documento-da-garantia
Aberto em 2026-08-18
Severidade ALTA
Apps afetados api · web · e2e · infra
Revisão do diagnóstico aprovado na rodada 3
Revisão da correção aprovada na rodada 2

Status: RASCUNHO → DIAGNOSTICADO → EM CORREÇÃO → CORRIGIDO

O fix atravessa dois portões, cada um revisado por um subagente de contexto limpo, em até 3 rodadas. Máquina do plugin superpowers, régua em .claude/rubrics/. Qualquer Critical reprova; Important em aberto reprova salvo decisão registrada do usuário.

  1. O diagnóstico (seções 1 a 4) é revisado na seção 5 (reprodução × causa raiz). É o portão barato: sem ele, o segundo mede o conserto contra um palpite.
  2. A correção (o diff e os testes) é revisada na seção 11 (correção × regressão), com duas perguntas — atacou a causa ou o sintoma, e o que prova que não volta.

1. Sintoma

Na tela da garantia, clicar num PDF anexado abre o modal de visualização com o nome do arquivo no cabeçalho e a área do conteúdo em branco — sem mensagem, sem erro, sem alternativa. O botão de baixar funciona.

Relatado pelo usuário no dev-remote, no app instalado (PWA) do iPhone/iPad, e também no navegador do mesmo aparelho. O arquivo anexado foi um PDF.

O 413 da seção 2a não foi relatado — foi descoberto aqui, tentando reproduzir o anexo do usuário com um arquivo maior. Ninguém tinha esbarrado nele porque os documentos anexados até hoje no dev-remote são pequenos: só três PDFs vivos passam de 20 KB.

Investigando, o próprio usuário reportou o achado que mudou o rumo: "o arquivo PDF que subi está corrompido" — e confirmou depois que no aparelho de origem ele também está quebrado, ou seja, subiu quebrado. Confirmou também o controle que faltava: um PDF íntegro abre normal no mesmo iPhone, pelo mesmo modal.

2. Reprodução

São dois defeitos distintos, e cada um tem a sua.

2a. Anexo acima de 1 MB é recusado (medido, determinístico)

  1. Autenticar-se no dev-remote e escolher qualquer garantia.
  2. Anexar um PDF acima de 1 MiB (medido: 900.000 bytes passam, 2 MB não) pelo painel — ou, equivalente, pelo mesmo caminho que o app usa (POST /api/v1/documents, multipart).
  3. A resposta é HTTP 413 com corpo HTML (<title>413 Request Entity Too Large</title>, rodapé nginx/1.24.0 (Ubuntu)), e o arquivo não chega à API.

Medido nas duas portas, com o mesmo resultado: pelo fetch do navegador, na origem do app, e por curl direto. Com 300 KB o mesmo caminho responde 201.

2b. Documento incompleto no banco abre em branco, calado

  1. Abrir a garantia cujo documento é o bio-marcus-vinicius-30-07-pdf.pdf (3cc17a5a-9654-428b-b263-70b1a1e24661, criado em 2026-08-18 11:05 UTC).
  2. Clicar para visualizar.
  3. O modal abre e a área do conteúdo fica vazia — nenhuma mensagem.

O arquivo guardado tem 258.048 bytes (252 KiB exatos), termina no meio de um fluxo binário e não contém %%EOF em lugar nenhum. Nenhum PDF válido termina sem esse marcador: o que está guardado é um PDF incompleto. Pior que isso: o primeiro objeto declara /Filter /DCTDecode /Length 413638 — um único stream maior que o arquivo inteiro, sem xref e sem árvore de páginas.

Sem depender daquele documento, a reprodução é uma linha — e é dela que o e2e vai precisar:

head -c 258048 qualquer-pdf-grande.pdf > truncado.pdf   # anexe este arquivo e clique para ver
  • Ambiente: dev-remote (dev.garantora.com.br). A produção não tem o nginx do 2a — app.garantora.com.br responde só com via: 1.1 Caddy, sem server: nginx.
  • Superfície: o 2a é de servidor, vale para desktop e mobile. O 2b foi observado no iPhone/iPad, no PWA instalado e no navegador do aparelho — e não é do aparelho: no mesmo iPhone, um PDF íntegro abre normalmente. O painel de documentos é o mesmo componente em inquilino, imóvel, análise e garantia.
  • Dados/pré-condições: qualquer papel com documents:create / documents:read.

3. Esperado × Atual

Esperado Atual
Anexo acima de 1 MiB e até 10 MB sobe (a API anuncia teto de 10 MB) 413 do nginx, com HTML; a tela mostra "Erro 413"
Anexo recusado pelo proxy mensagem que diz o tamanho máximo número de status cru: a tela recebe HTML e cai em "Erro 413"
Anexo recusado pela API mensagem de negócio já funciona — FILE_TOO_LARGE chega à tela com o texto certo
Arquivo incompleto ou ilegível recusado ao subir, ou avisado ao visualizar aceito calado; o preview abre em branco
PDF que o navegador não sabe exibir mensagem com a saída (baixar / abrir em outra aba) frame vazio, sem texto nenhum

4. Causa raiz

4a. O 413: um nginx no host, com o teto padrão de 1 MB

  • Onde: /etc/nginx/sites-enabled/dev.garantora.com.br — o server de dev.garantora.com.br faz proxy_pass http://127.0.0.1:8180 (o Caddy do dev-remote) e não declara client_max_body_size. Sem a diretiva, o nginx aplica o padrão de 1 MB e responde 413 antes de repassar o corpo.
  • O mecanismo: o corpo multipart inteiro é recusado no proxy da frente, então document_service.upload (apps/api/app/services/document_service.py:55, com o teto em :78-83) nunca roda — e é ele que carrega o limite real, MAX_UPLOAD_SIZE_MB = 10 (apps/api/app/core/config.py:61). O usuário vê um número de status porque documentsApi.upload (apps/web/src/shared/api/documents.ts:85-88) tenta res.json() sobre um corpo HTML, cai no catch, e monta Erro ${res.status}. Explica também por que só ali: a produção não tem esse nginx (medido pelos cabeçalhos), e o limite da API nunca chega a ser exercido no dev-remote.
  • O alcance: todo multipart que passe por dev.garantora.com.br — anexo de documento (POST /api/v1/documents), troca de conteúdo (PUT /api/v1/documents/{id}/content, document_service.replace_content, document_service.py:213) e qualquer upload futuro. Os outros vhosts do mesmo nginx declaram o próprio teto (app-dev.advfl.app 100m, api.plenapro.com.br 10M); o do Garantora ficou sem.

4b. O preview em branco: o que está guardado não é um PDF inteiro

  • Onde: o dado — documento 3cc17a5a-9654-428b-b263-70b1a1e24661, 258.048 bytes sem %%EOF. E o silêncio: apps/web/src/components/shared/DocumentsPanel.tsx:1150-1155 monta <iframe src={previewUrl}> para content_type === "application/pdf" e não tem ramo nenhum para "o frame não renderizou".
  • O mecanismo: o <iframe> delega a renderização ao visualizador de PDF do navegador, e o componente não tem como saber se ele desenhou alguma coisa. Medido, em headless: montando um <iframe> com blob application/pdf, nem onload nem onerror dispararam — em nenhum dos três motores (Chromium, WebKit e Firefox), e nem para o PDF íntegro nem para o truncado. O contentDocument volta vazio nos dois casos.

O que está provado é mais simples e não depende do motor: o componente não escuta nada — grep por onError/onLoad no arquivo inteiro volta vazio. Não há ramo de falha para desviar. Por isso a seção 6 propõe validar o conteúdo (a integridade do arquivo) em vez de esperar um evento cuja existência num navegador com visualizador ninguém mediu.

O componente já considerou o caminho feliz (previewUrl preenchido) e o "tipo não suportado" (getPreviewKind === "none", :146-159), mas não o terceiro caso: suportado, carregado e ainda assim não exibível. Sem evento e sem checagem de conteúdo, o que sobra é o branco.

Limite da medição, e ele importa: os três motores rodaram em modo headless, que não embute visualizador de PDF — nem um PDF íntegro é desenhado ali. Então o que está medido é a ausência de evento naquele ambiente, não a renderização, e não vale como prova de que um navegador com visualizador também não emitiria nada. O script da medição não ficou versionado: quem quiser reexecutar, monta um <iframe> com blob application/pdf e observa onload/onerror. A parte visual foi respondida pelo usuário: no iPhone, PDF íntegro abre. - O alcance, e ele tem duas direções.

Pelas telas: o DocumentsPanel é compartilhado — inquilino, imóvel, análise de crédito e garantia usam o mesmo modal (apps/web/src/pages/{renters,properties,credit-analyses,guarantees}). O sintoma apareceu na garantia porque foi lá que o arquivo quebrado foi anexado, não porque a garantia tenha algo de diferente: medido — o mesmo par de arquivos, na garantia e numa análise de crédito, produz exatamente o mesmo comportamento.

Pelos três ramos do próprio modal, que é onde a causa realmente mora: não há onError nem onLoad em lugar nenhum do arquivo (grep limpo). O <img> de :1165-1170 sofre da mesma falta — e ali, ao contrário do <iframe>, o onerror é confiável, então uma imagem truncada dá o mesmo branco calado por um caminho que teria conserto trivial. O ramo de texto (:1173) idem. Corrigir só o PDF conserta um de três.

O que ficou descartado, e com que medida

  • A API não corrompe nem trunca. Ida-e-volta byte a byte por POST /documents + GET /documents/{id}/download: 585 B e 300.586 B, md5 idêntico nos dois.
  • O armazenamento não trunca. Em todos os documentos vivos do dev-remote, size_bytes = length(content) — o que a API recebeu foi o que ela gravou. (Os excluídos logicamente têm content nulo, por desenho: o registro fica para auditoria e o conteúdo sai.) No arquivo do usuário os dois valores são 258.048: chegou incompleto.
  • Não é permissão nem escopo. GET /documents/{id}/download responde 200, com content-type: application/pdf e o número de bytes certo.
  • Não é o service worker do PWA. navigateFallbackDenylist: [/^\/api/] (apps/web/vite.config.ts:51) mantém /api fora do fallback, e o download volta 200 com o corpo certo.
  • Não é específico da garantia. Comparado com a análise de crédito: mesmo resultado.

  • Não é o aparelho nem o iOS. Era a hipótese concorrente mais forte — <iframe> com PDF tem má fama no WebKit. Caiu com o controle: no mesmo iPhone, um PDF íntegro abre normal (confirmado pelo usuário). O que não abre é o arquivo quebrado, e só ele.

De onde veio o arquivo quebrado — o que está medido e o que não está

Medido, e nesta ordem:

  • A perna de envio do navegador não trunca, ao menos no Chromium: pelo mesmo caminho do app (fetch + FormData, na origem do app, passando pelo nginx e pelo Caddy), três arquivos de 258.048, 414.000 e 900.000 bytes subiram e foram gravados com exatamente o mesmo tamanho. Os 258.048 são de propósito: é o tamanho do arquivo do usuário, e se houvesse corte em fronteira de buffer ali, apareceria.
  • A API e o banco não truncam: ida-e-volta byte a byte (md5 idêntico) em 585 B e 300.586 B por curl, e size_bytes = length(content) em todos os documentos vivos.
  • O usuário confirmou que o arquivo já está quebrado no aparelho de origem.

  • A cópia no aparelho tem os mesmos 258.048 bytes (o usuário conferiu em Arquivos → Info). É a medida que fechava a dúvida: o arquivo já estava cortado antes de sair do iPhone.

Por que a pergunta precisou ser feita, e não deduzida: 258.048 = 252 KiB exatos = 63 × 4096 é assinatura de corte em fronteira de buffer, e o corte cai dentro do primeiro objeto — que declara /Length 413638, ou seja, o arquivo completo passaria de 414 KB. Isso mantinha viva a hipótese de a leitura do Safari do iOS ter entregado o arquivo pela metade (o caso conhecido de arquivo do iCloud não baixado por inteiro), que nenhuma medição feita aqui exerceria — Chromium não é WebKit, e o iPhone não foi instrumentado. Com os dois tamanhos iguais, essa hipótese cai: o corte é anterior ao envio.

Consequência para a seção 10: recusar no upload não barra arquivo legítimo — o arquivo em questão não abria em lugar nenhum, nem no aparelho de origem. E o que a plataforma fez de errado continua sendo o mesmo: aceitou calada um PDF inutilizável e não disse nada na hora de exibir.

5. Revisão do diagnóstico

Preenchido pelo laço do /fix new, rubrica .claude/rubrics/diagnostico.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 2 3 reprovado
2 1 2 3 reprovado — Critical era procedência escrita como conclusão
3 0 0 3 aprovado

Resultado: aprovado na rodada 3.

Pendências aceitas: nenhuma — os três Minor da rodada 3 (a origem do 2a, o escopo da frase sobre eventos e duas linhas deslocadas em um) foram corrigidos no próprio registro.

6. Correção

infra (dev-remote)

  • Onde: /etc/nginx/sites-available/dev.garantora.com.br, no server de porta 443.
  • O quê: declarar client_max_body_size acima do teto da API (ex.: 12m para um MAX_UPLOAD_SIZE_MB = 10), para que quem recusa seja a API, com mensagem de negócio, e não o proxy, com HTML. Recarregar o nginx.
  • Nota: esse arquivo não é versionado neste repositório — é configuração do host. A correção precisa ser aplicada na box e registrada aqui.

apps/api

  • Onde: app/services/document_service.py, em upload (:55) e replace_content (:213).
  • O quê: recusar arquivo que se diz PDF e não é um PDF íntegro — assinatura %PDF- no começo e %%EOF presente. Erro de negócio próprio (CORRUPT_FILE), com mensagem que diga o que houve e o que fazer.

apps/web

  • Onde: src/components/shared/DocumentsPanel.tsx (modal de visualização) e src/shared/api/documents.ts (upload, replaceContent).
  • O quê:
  • Detectar antes de montar o frame, no próprio blob. O diagnóstico mostrou que o navegador não avisa: nem onload nem onerror disparam. Então a checagem tem de ser do conteúdo — o fetchBlobUrl já tem os bytes em mãos, e ler o fim do arquivo à procura do %%EOF custa nada. É isto que cobre o que já está gravado, que a validação do upload não alcança.
  • Dizer, com saída. Mensagem no lugar do frame vazio, mais baixar e abrir em outra aba.
  • Ramo de falha nos outros dois formatos: onError no <img> (que é confiável) e o equivalente no texto.
  • Traduzir o 413 em texto que diga o limite, em vez de repassar o número de status.
  • data-testid: o aviso do preview e o botão de abrir em outra aba.
  • Responsividade & PWA: o modal é o mesmo nos dois viewports; conferir em 375px.

apps/e2e

  • Teste de regressão: anexar um PDF truncado e verificar que a tela diz o que houve — em vez de abrir em branco.

7. Risco / impacto colateral

  • Subir o teto do nginx aumenta o corpo que a box aceita por requisição. O limite real continua sendo o da API (10 MB), que responde com erro de negócio; o do proxy passa a ser só a folga. Sem efeito em dinheiro, cálculo ou dado existente.
  • Validar integridade no upload pode recusar arquivo que hoje entra. É o ponto: um PDF sem %%EOF já não serve para nada — não abre no preview nem no leitor de quem baixar. O risco a vigiar é o oposto, falso positivo: PDF válido cujo %%EOF esteja além de onde se procurou. A checagem tem de olhar o fim do arquivo, não os primeiros bytes, e a validação vale só para application/pdf — os outros formatos aceitos não têm marcador equivalente.
  • O modal é compartilhado por quatro telas (inquilino, imóvel, análise, garantia): mexer no estado de preview afeta as quatro. É o mesmo componente que a spec 0037 usa para a troca de conteúdo.
  • Nada disso toca a produção hoje — o 413 é do host do dev-remote. Mas a validação de integridade e o aviso do preview vão para a produção no primeiro deploy depois do merge.

8. Critérios de aceite

  • [x] CA1. Seguindo a reprodução 2a, um PDF de 2 MB sobe no dev-remote — medido depois do client_max_body_size 12m: HTTP 201, 2.097.152 bytes gravados, pelo mesmo caminho que devolvia 413.
  • [x] CA2. Um arquivo acima do teto da API é recusado pela API, com mensagem de negócio — nunca com HTML de proxy. Medido com 11 MB: 422 FILE_TOO_LARGE, "O arquivo enviado excede o tamanho máximo permitido de 10 MB."
  • [x] CA3. A tela mostra o motivo e o tamanho máximo quando a recusa acontece.
  • [x] CA4. Anexar um PDF truncado é recusado no upload, com mensagem que diz o que houve.
  • [x] CA5. Abrindo um documento que a tela não consegue exibir, o modal diz isso e oferece baixar — nenhum estado deixa a área de conteúdo vazia e muda. Vale para os três ramos: PDF (integridade dos bytes), imagem (onError) e texto (conteúdo em branco).
  • [x] CA6. Os documentos íntegros continuam visualizando normalmente — PDF, imagem e texto. Coberto pelos controles das duas suítes (test_pdf_integro_continua_subindo, test_arquivo_que_nao_se_anuncia_pdf_nao_e_afetado e o e2e "PDF inteiro continua entrando"). No iPhone não foi reconferido depois da correção — a linha de base é a medida do usuário durante o diagnóstico: PDF íntegro abre, PDF quebrado não.

9. Plano

  • [x] Escrever o teste que reproduz o bug e vê-lo falhar (apps/api/tests/ e/ou apps/e2e/) — antes de qualquer correção
  • [x] Corrigir a causa raiz — na ordem api → web → e2e se atravessar camadas
  • [x] Red-green verificado: teste passa → reverter a correção → rodar → falha → restaurar → passa. Se o fix é só visual, a prova é o script de screenshot nos dois viewports, com o que se conferiu
  • [x] make test e make lint sem apontamento novo nos arquivos tocados
  • [x] Se mexeu no web, validar no browser de verdade em desktop e mobile (e, se for build/PWA, reabrir a tela instalada para furar o cache)

10. Decisões em aberto

  • [x] Recusar no upload, ou só avisar ao visualizar? — os dois, e a dúvida que segurava isso caiu com a medida: o arquivo tem 258.048 bytes também no aparelho, então recusar não barra arquivo legítimo. Recusar cobre o que entra; avisar cobre o que já está no banco, que a validação de upload nunca alcança.
  • [x] Que teto declarar no nginx do dev-remote? — 12m, aprovado pelo usuário e aplicado em /etc/nginx/sites-available/dev.garantora.com.br (cópia do original em .bak-fix0010), com nginx -t e reload. A folga sobre os 10 MB da API é o que faz a recusa ser sempre da API. Mudou o teto de negócio, mudam os dois juntos.

11. Revisão da correção

Preenchido pelo laço do /fix implement, rubrica .claude/rubrics/correcao.md. Registrar todas as rodadas.

Rodada Critical Important Minor Veredicto
1 1 5 2 reprovada
2 0 1 5 aprovada — o Important é a decisão pendente da seção 10

O teste sobreviveria à reversão? Sim — os dois lados, medidos com comando.

  • API. Com _recusar_pdf_incompleto comentado nos dois pontos de chamada: pytest -k truncado → 2 failed. Restaurada → 2 passed.
  • Web. Com a checagem do preview neutralizada (if (false && pdfNaoExibivel(bytes))) e o container reconstruído — a imagem embute o código, então reverter sem rebuild não mede nada: o teste do documento legado falha em expect(getByTestId('doc-preview-falha')).toBeVisible() com "element(s) not found", e os outros quatro seguem verdes, porque não passam por esse ramo. Restaurada e reconstruída: 5 passed.

Foi o Critical da rodada 1, e ele estava certo: a metade que conserta o sintoma relatado não tinha prova. A fixture ficou difícil por causa do próprio fix — a API agora recusa o arquivo cortado na porta de entrada —, e o caminho foi criar o documento íntegro pela API e truncá-lo direto no banco, que é o retrato exato do que existia antes da validação.

Corrigido ainda na rodada 2, dos Minor apontados: a assinatura %PDF- passou a ser procurada no primeiro KB e não no byte 0 (PDF com BOM abre no navegador, e dizer que não abre mandaria a pessoa procurar outra via de um arquivo bom); pdfIncompleto foi removido, que era export sem consumidor e divergiria do Python sem ninguém notar; e entraram dois testes — o 413 encenado com page.route (fecha CA3 sem depender do nginx) e o texto em branco (fecha o terceiro ramo do CA5).

Defeitos aceitos:

  • A configuração do nginx não é versionada aqui — mora no host, e a única memória dela é este registro mais o comentário na própria diretiva. Se a box for reprovisionada, o 413 volta. É dívida de infra conhecida, não deste fix.
  • replace_content não chama _garantir_janela_da_imobiliaria (document_service.py:213), então a imobiliária troca o arquivo mesmo com a análise na mão da garantidora — o que o upload impede. É anterior a este fix e o diff não toca esse método; achado do revisor, registrado aqui porque o botão novo do modal alarga a superfície. Vale um /fix próprio.

12. Registro

O que era, e o que virou

  • A API aceitava qualquer sequência de bytes. Passa a recusar, nas duas portas (upload e replace_content), o arquivo que começa com %PDF- e não tem %%EOF na cauda: 422 CORRUPT_FILE, com mensagem que diz o que houve e o que fazer.
  • O modal tinha dois estados e precisava de três. Ganhou "carregado e ainda assim não exibível", com mensagem e duas saídas (baixar, anexar outra via) — nos três ramos: PDF (conferindo os bytes antes de montar o <iframe>), imagem (onError, que ali é confiável) e texto (conteúdo em branco).
  • A recusa do proxy virava "Erro 413". Agora vira texto, com o limite à vista.

O que divergiu do plano

  • A regra do upload e a do visualizador ficaram diferentes, de propósito. No upload ela é estreita (só o que se anuncia PDF e está cortado): barrar por assinatura ausente recusaria envio que hoje funciona, por motivo que ninguém pediu. No visualizador é mais larga, porque a pergunta é outra — "dá para desenhar isto?" —, e um application/pdf que não é PDF nenhum produzia exatamente o mesmo retângulo branco. Veio de achado da rodada 1.
  • 18 fixtures de teste passaram a incluir %%EOF (quatro arquivos de apps/api/tests e dois specs de e2e). Não é carona: são o arranjo que a regra nova exige, e nenhuma asserção foi afrouxada. Sem isso, dois specs de e2e passariam a falhar de forma determinística — foi achado da rodada 1, e a fase de API sozinha não teria percebido.
  • A parte de infra (o teto do nginx) não foi aplicada. É a decisão pendente da seção 10 e mora fora deste repositório; CA1 e CA2 seguem em aberto por isso.

O que se aprendeu

  • Reverter correção de web sem reconstruir o container não mede nada — a imagem embute o código. O red-green do lado do web custou dois rebuilds, e é o preço de ter prova em vez de impressão.
  • O documento do bug não podia mais ser criado pela porta da frente depois da correção. O caminho foi criar o documento íntegro pela API e truncá-lo direto no banco — o retrato do que existia antes da validação.