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.
- 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.
- 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)¶
- Autenticar-se no dev-remote e escolher qualquer garantia.
- 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). - A resposta é
HTTP 413com 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¶
- 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). - Clicar para visualizar.
- 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:
- Ambiente: dev-remote (
dev.garantora.com.br). A produção não tem o nginx do 2a —app.garantora.com.brresponde só comvia: 1.1 Caddy, semserver: 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— oserverdedev.garantora.com.brfazproxy_pass http://127.0.0.1:8180(o Caddy do dev-remote) e não declaraclient_max_body_size. Sem a diretiva, o nginx aplica o padrão de 1 MB e responde413antes 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 porquedocumentsApi.upload(apps/web/src/shared/api/documents.ts:85-88) tentares.json()sobre um corpo HTML, cai nocatch, e montaErro ${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
multipartque passe pordev.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.app100m,api.plenapro.com.br10M); 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-1155monta<iframe src={previewUrl}>paracontent_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 blobapplication/pdf, nemonloadnemonerrordispararam — em nenhum dos três motores (Chromium, WebKit e Firefox), e nem para o PDF íntegro nem para o truncado. OcontentDocumentvolta 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 blobapplication/pdfe observaonload/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êmcontentnulo, 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}/downloadresponde200, comcontent-type: application/pdfe o número de bytes certo. - Não é o service worker do PWA.
navigateFallbackDenylist: [/^\/api/](apps/web/vite.config.ts:51) mantém/apifora do fallback, e o download volta200com 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 (
md5idêntico) em 585 B e 300.586 B porcurl, esize_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, noserverde porta 443. - O quê: declarar
client_max_body_sizeacima do teto da API (ex.:12mpara umMAX_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, emupload(:55) ereplace_content(:213). - O quê: recusar arquivo que se diz PDF e não é um PDF íntegro — assinatura
%PDF-no começo e%%EOFpresente. 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) esrc/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
onloadnemonerrordisparam. Então a checagem tem de ser do conteúdo — ofetchBlobUrljá tem os bytes em mãos, e ler o fim do arquivo à procura do%%EOFcusta 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:
onErrorno<img>(que é confiável) e o equivalente no texto. - Traduzir o
413em 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
%%EOFjá 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%%EOFesteja 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ó paraapplication/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 devolvia413. - [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_afetadoe 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/ouapps/e2e/) — antes de qualquer correção - [x] Corrigir a causa raiz — na ordem
api → web → e2ese 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 testemake lintsem 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), comnginx -tereload. 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_incompletocomentado 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 emexpect(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
413volta. É dívida de infra conhecida, não deste fix. replace_contentnã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 ouploadimpede. É 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/fixpróprio.
12. Registro¶
O que era, e o que virou¶
- A API aceitava qualquer sequência de bytes. Passa a recusar, nas duas portas (
uploadereplace_content), o arquivo que começa com%PDF-e não tem%%EOFna 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/pdfque 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 deapps/api/testse 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.