Browse Source

Fix(Assinatura): falha em massa vira uma linha legivel e URL de outra instancia nao converte AB#1473 (#19)

Duas lacunas que o acervo real de Franco expos em 22/08/2026, as duas sobre a
materializacao falhar sem que ninguem consiga agir.

1. FALHA EM MASSA AGREGADA POR MOTIVO

`2 gerados, 873 falhas` passava por linha de rotina, e as 873 eram todas o MESMO
erro — 873 logger.error dispersos que ninguem le. O aviso anterior so disparava
com ZERO gerados, entao duas materias PDF passando escondiam o lote inteiro de
DOCX parado. Agora o motivo dominante sai numa linha com a contagem. Uma falha
isolada continua sem gritar: materia podre avulsa e ruido esperado (§5.1).

Junto vai a dica que evita consertar a variavel errada: `codigo -8` do OnlyOffice
e erro de TOKEN, nao de URL. Eu mesmo li errado primeiro e mandei configurar
SAPL_INTERNAL_URL. JWT desligado AQUI e exatamente o que produz -8 quando o
SERVIDOR do OnlyOffice exige assinatura — provado com POST direto ao
ConvertService, sem token, com URL publica respondendo 200: devolve -8. Se fosse
download quebrado seria -4. A dica so aparece com ONLYOFFICE_JWT_ENABLED=False;
com JWT ligado o -8 e outra coisa e a dica viraria pista falsa.

2. CONFERENCIA DA ORIGEM SERVIDA

`SAPL_INTERNAL_URL` e configuracao de operador e nao ha de onde deduzi-la: nao ha
contrib.sites, ALLOWED_HOSTS e ['*'] e no cron nao existe request. E, mais a
fundo, so quem opera sabe qual URL o servidor do OnlyOffice alcanca. O risco nao
e ela ser fixa — e ela apontar para OUTRA instancia em silencio: o hash_origem
sai do arquivo local e o PDF-alvo do arquivo do outro SAPL. Como e esse hash que
dispara a retificacao (§5.1), o alvo defasado nunca mais e regenerado e assina-se
um PDF que nao corresponde ao texto da materia.

Baixar a propria URL e comparar o sha256 fecha isso sem adivinhacao: seja qual
for o valor configurado, so passa se servir ESTE documento. Rodado contra o
ambiente local com SAPL_INTERNAL_URL apontando para demo.legisinc.com.br, pegou
na hora — materias 1066 e 1072 sao documentos diferentes nas duas instancias.
So o caminho DOCX confere; PDF copia bytes e nao toca o OnlyOffice.

Testes: 70 passed em sapl/integracao_hub/tests/ (eram 64).
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
2a1edc4a4a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 102
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  2. 159
      sapl/integracao_hub/tests/test_materializacao.py

102
sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py

@ -3,6 +3,7 @@ import logging
import os
import time
import requests as http_requests
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand, CommandError
@ -34,6 +35,44 @@ class _RequisicaoDeSistema:
return base.rstrip('/') + caminho
def _origem_servida_confere(materia, hash_origem):
"""A URL entregue ao OnlyOffice serve MESMO o documento que acabamos de ler?
`SAPL_INTERNAL_URL` e configuracao de operador e nao ha de onde deduzi-la: so
quem opera sabe qual URL o servidor do OnlyOffice alcanca. O risco nao e ela
ser fixa e ela apontar para OUTRA instancia sem ninguem perceber. Ai o
`hash_origem` sai do arquivo local e o PDF-alvo sai do arquivo do outro SAPL,
e como e justamente esse hash que dispara a retificacao (§5.1), o alvo
defasado nunca mais e regenerado: assina-se um PDF que nao corresponde ao
texto da materia.
Baixar a propria URL e comparar o sha256 fecha isso sem exigir adivinhacao:
seja qual for o valor configurado, ele so passa se servir este documento.
Retorna (ok, motivo).
"""
from django.urls import reverse
from sapl.utils import build_onlyoffice_url
url = build_onlyoffice_url(
_RequisicaoDeSistema(),
reverse('sapl.materia:materia_onlyoffice_download',
kwargs={'pk': materia.pk}))
try:
resposta = http_requests.get(url, timeout=60)
except Exception as exc: # noqa — rede e diagnostico, nao excecao de dominio
return False, ('a URL entregue ao OnlyOffice nao respondeu (%s) — '
'confira SAPL_INTERNAL_URL' % type(exc).__name__)
if resposta.status_code != 200:
return False, ('a URL entregue ao OnlyOffice respondeu %s — o servidor '
'dele tambem nao vai conseguir baixar'
% resposta.status_code)
if hashlib.sha256(resposta.content).hexdigest() != hash_origem:
return False, ('a URL entregue ao OnlyOffice serve OUTRO documento — '
'SAPL_INTERNAL_URL aponta para outra instancia do SAPL. '
'Converter assim gera um PDF-alvo que nao corresponde ao '
'texto desta materia')
return True, None
def _base_url_de_sistema():
"""URL que o OnlyOffice usa para BAIXAR o documento de origem, no caminho cron.
@ -104,14 +143,17 @@ class Command(BaseCommand):
.order_by('id'))
gerados = retificados = pulados = falhas = 0
motivos = {}
for materia in materias.iterator():
try:
resultado = self._materializar(materia)
resultado, motivo = self._materializar(materia)
except Exception as exc: # noqa — uma matéria não trava as demais (§5.1)
logger.exception(
'materializar_pdfs: falha inesperada na matéria %s: %s',
materia.pk, exc)
falhas += 1
motivos[type(exc).__name__] = motivos.get(
type(exc).__name__, 0) + 1
continue
if resultado == 'gerado':
gerados += 1
@ -119,6 +161,7 @@ class Command(BaseCommand):
retificados += 1
elif resultado == 'falha':
falhas += 1
motivos[motivo] = motivos.get(motivo, 0) + 1
else:
pulados += 1
@ -126,18 +169,34 @@ class Command(BaseCommand):
'materializar_pdfs: %s gerados, %s retificados, %s em dia, '
'%s falhas' % (gerados, retificados, pulados, falhas))
# Falha sem NENHUM avanco nao e materia podre avulsa: e o ambiente
# inteiro parado (OnlyOffice fora do ar, URL que ele nao alcanca, MEDIA
# sem os binarios). Some do log comum porque cada materia falha
# individualmente e o resumo parece so mais uma linha de rotina.
if falhas and not gerados and not retificados:
aviso = (
'materializar_pdfs: %s falhas e NENHUM PDF-alvo gerado — isso e '
'ambiente, nao documento. Confira o OnlyOffice em %s e se ele '
'alcanca %s; enquanto isso nenhuma materia DOCX vira pendencia '
'de assinatura no app.' % (
falhas, getattr(settings, 'ONLYOFFICE_URL', '<ausente>'),
_base_url_de_sistema()))
# Falha em MASSA por um motivo so nao e materia podre avulsa: e o
# ambiente parado. O contador sozinho nao dizia isso — `2 gerados, 873
# falhas` passava por linha de rotina, e as 873 eram todas o MESMO erro.
# Agrupar por motivo transforma 873 logger.error dispersos em uma linha
# que se le e se age. Em 22/08/2026 essas 873 eram um unico `codigo -8`.
self._relatar_motivos(motivos, falhas)
def _relatar_motivos(self, motivos, falhas):
if not motivos:
return
motivo, quantas = max(motivos.items(), key=lambda par: par[1])
# Uma falha isolada e ruido esperado (§5.1); o que precisa gritar e o
# motivo unico que derruba um lote inteiro.
if quantas < 2:
return
aviso = ('materializar_pdfs: %s de %s falhas pelo MESMO motivo: %s'
% (quantas, falhas, motivo))
# O -8 do OnlyOffice e "token invalido", e a leitura natural (URL ruim)
# manda consertar a variavel errada: JWT desligado AQUI e exatamente o
# que produz -8 quando o SERVIDOR do OnlyOffice exige token.
if 'codigo -8' in motivo or 'código -8' in motivo:
if not getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False):
aviso += (
' | -8 e erro de TOKEN, nao de URL: o servidor em %s exige '
'JWT e ONLYOFFICE_JWT_ENABLED esta False. Configure '
'ONLYOFFICE_JWT_ENABLED=True e ONLYOFFICE_JWT_SECRET com o '
'mesmo segredo do servidor do OnlyOffice.'
% getattr(settings, 'ONLYOFFICE_URL', '<ausente>'))
self.stderr.write(aviso)
logger.error(aviso)
@ -151,11 +210,20 @@ class Command(BaseCommand):
alvo = DocumentoParaAssinatura.objects.filter(materia=materia).first()
if alvo is not None and alvo.hash_origem == hash_origem:
return 'em dia' # idempotência: nada mudou desde a geração
return 'em dia', None # idempotência: nada mudou desde a geração
# Reuso da rotina da sprint: PDF copia os bytes, DOCX converte no
# OnlyOffice — a conversão acontece UMA vez, aqui, fora do caminho
# quente das requisições (§5.1).
# Só o caminho DOCX passa pelo OnlyOffice; PDF copia bytes e não depende
# de URL nenhuma. Conferir a origem servida antes de gastar a conversão.
if not materia.texto_original.name.lower().endswith('.pdf'):
ok, motivo = _origem_servida_confere(materia, hash_origem)
if not ok:
logger.error(
'materializar_pdfs: matéria %s%s', materia.pk, motivo)
return 'falha', motivo
from sapl.materia.views_assinatura import _gerar_pdf_da_materia
pdf_bytes, erro = _gerar_pdf_da_materia(
materia, _RequisicaoDeSistema())
@ -164,7 +232,7 @@ class Command(BaseCommand):
'materializar_pdfs: matéria %s não convertida (%s) — segue '
'visível só na tela do SAPL até o próximo ciclo', materia.pk,
erro)
return 'falha'
return 'falha', erro
nome = 'materia_%s_alvo.pdf' % materia.pk
hash_alvo = hashlib.sha256(pdf_bytes).hexdigest()
@ -178,7 +246,7 @@ class Command(BaseCommand):
logger.info(
'materializar_pdfs: PDF-alvo da matéria %s gerado (%s)',
materia.pk, hash_alvo)
return 'gerado'
return 'gerado', None
# RETIFICAÇÃO (§5.1, decisão do arquiteto 19/08): texto_original
# mudou depois da conversão → o alvo está defasado. Regenera E zera
@ -202,4 +270,4 @@ class Command(BaseCommand):
logger.info(
'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) '
'e assinaturas zeradas', materia.pk, hash_alvo)
return 'retificado'
return 'retificado', None

159
sapl/integracao_hub/tests/test_materializacao.py

@ -238,22 +238,167 @@ def test_site_url_sozinha_basta(db, settings):
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()
MOD = ('sapl.integracao_hub.management.commands.'
'materializar_pdfs_para_assinatura')
@pytest.fixture
def origem_confere(monkeypatch):
"""Neutraliza a conferência de integridade — ela tem os seus próprios testes."""
monkeypatch.setattr(MOD + '._origem_servida_confere',
lambda materia, hash_origem: (True, None))
@pytest.mark.django_db(transaction=False)
def test_passada_sem_nenhum_avanco_grita_no_stderr(db, monkeypatch, capsys):
"""Falha sem nenhum gerado é ambiente parado, não documento podre avulso.
def test_falha_em_massa_pelo_mesmo_motivo_grita_no_stderr(
db, monkeypatch, capsys, origem_confere):
"""873 `logger.error` dispersos não se leem; uma linha agregada, sim.
É o resumo que passava por linha de rotina: `0 gerados, 873 falhas` não se
distingue de um dia normal no meio do log.
O contador sozinho não denunciava nada: `2 gerados, 873 falhas` passava por
linha de rotina, e as 873 eram todas o MESMO erro (22/08/2026).
"""
criar_materia(protocolo=201, nome='a.docx', conteudo=b'docx')
criar_materia(protocolo=202, nome='b.docx', conteudo=b'docx')
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda materia, request: (None, 'OnlyOffice: codigo -8'))
lambda materia, request: (None, 'Erro na conversao: codigo -7'))
call_command('materializar_pdfs_para_assinatura')
erro = capsys.readouterr().err
assert '2 de 2 falhas pelo MESMO motivo' in erro
assert 'codigo -7' in erro
@pytest.mark.django_db(transaction=False)
def test_falha_isolada_nao_grita(db, monkeypatch, capsys, origem_confere):
"""Uma matéria podre é ruído esperado (§5.1) — só o lote inteiro grita."""
criar_materia(protocolo=203, nome='a.docx', conteudo=b'docx')
criar_materia(protocolo=204) # PDF: passa
def gerar(materia, request):
if materia.numero_protocolo == 203:
return None, 'OnlyOffice fora do ar'
return PDF, None
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia', gerar)
call_command('materializar_pdfs_para_assinatura')
assert 'MESMO motivo' not in capsys.readouterr().err
@pytest.mark.django_db(transaction=False)
def test_codigo_8_aponta_jwt_e_nao_url(
db, monkeypatch, capsys, settings, origem_confere):
"""O `-8` do OnlyOffice manda consertar a variável errada se lido ao pé da letra.
A leitura natural é "URL ruim". É token: JWT desligado AQUI é exatamente o
que produz -8 quando o SERVIDOR do OnlyOffice exige assinatura. Provado em
22/08/2026 com POST direto ao ConvertService, URL pública respondendo 200.
"""
settings.ONLYOFFICE_JWT_ENABLED = False
settings.ONLYOFFICE_URL = 'https://onlyoffice.exemplo'
criar_materia(protocolo=205, nome='a.docx', conteudo=b'docx')
criar_materia(protocolo=206, nome='b.docx', conteudo=b'docx')
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda materia, request: (
None, 'Erro na conversao do documento: codigo -8'))
call_command('materializar_pdfs_para_assinatura')
erro = capsys.readouterr().err
assert 'erro de TOKEN, nao de URL' in erro
assert 'ONLYOFFICE_JWT_ENABLED=True' in erro
assert 'https://onlyoffice.exemplo' in erro
@pytest.mark.django_db(transaction=False)
def test_jwt_ligado_nao_repete_a_dica(
db, monkeypatch, capsys, settings, origem_confere):
"""Com JWT já ligado o -8 é outra coisa — a dica viraria pista falsa."""
settings.ONLYOFFICE_JWT_ENABLED = True
criar_materia(protocolo=207, nome='a.docx', conteudo=b'docx')
criar_materia(protocolo=208, nome='b.docx', conteudo=b'docx')
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda materia, request: (
None, 'Erro na conversao do documento: codigo -8'))
call_command('materializar_pdfs_para_assinatura')
erro = capsys.readouterr().err
assert 'NENHUM PDF-alvo gerado' in erro
assert 'e ambiente, nao documento' in erro
assert 'MESMO motivo' in erro
assert 'erro de TOKEN' not in erro
# ---------------------------------------------------------------------------
# Integridade da origem: a URL entregue ao OnlyOffice serve ESTE documento?
# ---------------------------------------------------------------------------
class _RespostaFalsa:
def __init__(self, conteudo, status_code=200):
self.content = conteudo
self.status_code = status_code
@pytest.mark.django_db(transaction=False)
def test_url_que_serve_outro_documento_nao_converte(db, monkeypatch, caplog):
"""O acidente que isso impede: SAPL_INTERNAL_URL apontando para outra instância.
O `hash_origem` sairia do arquivo local e o PDF-alvo do arquivo do outro
SAPL. Como é esse hash que dispara a retificação (§5.1), o alvo defasado
nunca mais seria regenerado assinaria-se um PDF que não corresponde ao
texto da matéria. Falha em vez de converter.
"""
materia = criar_materia(protocolo=209, nome='a.docx', conteudo=b'docx-local')
monkeypatch.setattr(
MOD + '.http_requests.get',
lambda url, timeout: _RespostaFalsa(b'docx-de-outra-instancia'))
converteu = []
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda m, r: converteu.append(m) or (PDF, None))
call_command('materializar_pdfs_para_assinatura')
assert not converteu, 'não pode nem tentar converter'
assert not DocumentoParaAssinatura.objects.filter(materia=materia).exists()
# Uma matéria só não aciona o grito agregado (isso é ruído esperado) —
# o motivo tem que estar no log, nomeando a variável a consertar.
assert 'serve OUTRO documento' in caplog.text
assert 'SAPL_INTERNAL_URL' in caplog.text
@pytest.mark.django_db(transaction=False)
def test_url_que_serve_o_documento_certo_converte(db, monkeypatch):
materia = criar_materia(protocolo=210, nome='a.docx', conteudo=b'docx-local')
monkeypatch.setattr(
MOD + '.http_requests.get',
lambda url, timeout: _RespostaFalsa(b'docx-local'))
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda m, r: (PDF, None))
call_command('materializar_pdfs_para_assinatura')
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()
@pytest.mark.django_db(transaction=False)
def test_pdf_nao_passa_pela_conferencia_de_url(db, monkeypatch):
"""PDF copia bytes e não toca o OnlyOffice — não faz sentido exigir URL boa."""
def explode(*args, **kwargs):
raise AssertionError('PDF não deveria conferir URL')
monkeypatch.setattr(MOD + '.http_requests.get', explode)
materia = criar_materia(protocolo=211)
call_command('materializar_pdfs_para_assinatura')
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()

Loading…
Cancel
Save