Browse Source

Fix(Assinatura): falha em massa diz o motivo, e a varredura nao apaga assinatura AB#1473 (#20)

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

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).

* Fix(Assinatura): falha em massa diz o motivo, e a varredura nao apaga assinatura AB#1473

O #17 fez a materializacao recusar rodar sem base URL. Faltava o resto: com base
URL configurada e o OnlyOffice recusando a conversao, as 861 falhas de Franco
voltavam a ser 861 logger.error dispersos, e o aviso de lote so disparava quando
NENHUM PDF-alvo era gerado — bastava um dos 21 PDF passar na mesma passada para
o alerta sumir.

Tres mudancas:

1. As falhas sao agrupadas por MOTIVO e o motivo dominante grita. `873 falhas`
   nao diz nada; `873 de 873 falhas pelo MESMO motivo: OnlyOffice codigo -8` diz
   que o ambiente esta parado. Nao depende mais de zero gerados.

2. O -8 ganha o diagnostico certo. A leitura natural (URL ruim) manda consertar
   a variavel errada: -8 e erro de TOKEN. Com `ONLYOFFICE_JWT_ENABLED=False` e o
   servidor do OnlyOffice exigindo JWT, e exatamente esse o codigo. Provado em
   22/08/2026 com POST direto ao ConvertService.ashx: URL publica respondendo
   200, sem token, devolve -8 (download quebrado seria -4).

3. `--somente-novos`: gera o alvo AUSENTE e nunca retifica. Zerar assinatura em
   retificacao (§5.1) foi decidido para o ato isolado de retificar um texto —
   quem retifica sabe o que esta desfazendo. Numa passada sobre o acervo inteiro
   ninguem pediu isso, e apagar assinatura e irreversivel. A decisao acontece
   ANTES da conversao: adiar depois seria pagar o OnlyOffice para jogar fora.

Junto vai `_origem_servida_confere`: antes de gastar a conversao de um DOCX,
baixa a propria URL entregue ao OnlyOffice e compara o sha256 com o texto lido.
SAPL_INTERNAL_URL apontando para OUTRA instancia gera um PDF-alvo que nao
corresponde ao texto da materia — e como e o `hash_origem` que dispara a
retificacao, o alvo defasado nunca mais seria regenerado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
1910280e3e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 47
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  2. 100
      sapl/integracao_hub/tests/test_materializacao.py

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

@ -106,6 +106,17 @@ class Command(BaseCommand):
'que e como o container sobe a rotina (start.sh): sem isso a ' 'que e como o container sobe a rotina (start.sh): sem isso a '
'materializacao vira passo manual e materia protocolada NUNCA ' 'materializacao vira passo manual e materia protocolada NUNCA '
'vira pendencia no app, em silencio.')) 'vira pendencia no app, em silencio.'))
parser.add_argument(
'--somente-novos',
action='store_true',
help=('Gera apenas o PDF-alvo AUSENTE; nunca entra na retificacao. '
'E o modo da passada de recuperacao: quando um acervo inteiro '
'materializa de uma vez (o dia em que a conversao DOCX volta '
'a funcionar), retificar em lote apagaria assinatura ja feita '
'— e a decisao de zerar assinatura (§5.1) foi tomada para o '
'ato isolado de retificar um texto, nao para uma varredura. '
'A retificacao adiada aparece no resumo e roda no ciclo '
'normal, uma a uma.'))
def handle(self, *args, **options): def handle(self, *args, **options):
# Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por # Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por
@ -121,32 +132,33 @@ class Command(BaseCommand):
'que o servidor do OnlyOffice alcance.') 'que o servidor do OnlyOffice alcance.')
intervalo = options['intervalo'] intervalo = options['intervalo']
somente_novos = options['somente_novos']
if intervalo <= 0: if intervalo <= 0:
self._passada() self._passada(somente_novos)
return return
self.stdout.write( self.stdout.write(
'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo) 'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo)
while True: while True:
try: try:
self._passada() self._passada(somente_novos)
except Exception as exc: # noqa — o laco NUNCA morre: se morrer, except Exception as exc: # noqa — o laco NUNCA morre: se morrer,
# a materializacao para de vez e ninguem percebe ate a materia # a materializacao para de vez e ninguem percebe ate a materia
# nao aparecer para assinar. # nao aparecer para assinar.
logger.exception('materializar_pdfs: passada falhou: %s', exc) logger.exception('materializar_pdfs: passada falhou: %s', exc)
time.sleep(intervalo) time.sleep(intervalo)
def _passada(self): def _passada(self, somente_novos=False):
materias = (MateriaLegislativa.objects materias = (MateriaLegislativa.objects
.filter(numero_protocolo__isnull=False, .filter(numero_protocolo__isnull=False,
texto_original__isnull=False) texto_original__isnull=False)
.exclude(texto_original='') .exclude(texto_original='')
.order_by('id')) .order_by('id'))
gerados = retificados = pulados = falhas = 0 gerados = retificados = pulados = falhas = adiados = 0
motivos = {} motivos = {}
for materia in materias.iterator(): for materia in materias.iterator():
try: try:
resultado, motivo = self._materializar(materia) resultado, motivo = self._materializar(materia, somente_novos)
except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1)
logger.exception( logger.exception(
'materializar_pdfs: falha inesperada na matéria %s: %s', 'materializar_pdfs: falha inesperada na matéria %s: %s',
@ -162,6 +174,8 @@ class Command(BaseCommand):
elif resultado == 'falha': elif resultado == 'falha':
falhas += 1 falhas += 1
motivos[motivo] = motivos.get(motivo, 0) + 1 motivos[motivo] = motivos.get(motivo, 0) + 1
elif resultado == 'adiado':
adiados += 1
else: else:
pulados += 1 pulados += 1
@ -169,6 +183,16 @@ class Command(BaseCommand):
'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' 'materializar_pdfs: %s gerados, %s retificados, %s em dia, '
'%s falhas' % (gerados, retificados, pulados, falhas)) '%s falhas' % (gerados, retificados, pulados, falhas))
# Adiada nao e "em dia": e trabalho pendente que este modo se recusou a
# fazer. Some do resumo comum e vira surpresa quando alguem estranhar
# que o alvo nao acompanhou o texto.
if adiados:
self.stdout.write(
'materializar_pdfs: %s retificacao(oes) ADIADA(S) por '
'--somente-novos — o texto mudou e o PDF-alvo segue defasado. '
'Rodar sem a flag para regenerar (zera a assinatura da materia, '
'§5.1).' % adiados)
# Falha em MASSA por um motivo so nao e materia podre avulsa: e o # 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 # 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. # falhas` passava por linha de rotina, e as 873 eram todas o MESMO erro.
@ -200,7 +224,7 @@ class Command(BaseCommand):
self.stderr.write(aviso) self.stderr.write(aviso)
logger.error(aviso) logger.error(aviso)
def _materializar(self, materia): def _materializar(self, materia, somente_novos=False):
materia.texto_original.open('rb') materia.texto_original.open('rb')
try: try:
conteudo_origem = materia.texto_original.read() conteudo_origem = materia.texto_original.read()
@ -212,6 +236,17 @@ class Command(BaseCommand):
if alvo is not None and alvo.hash_origem == hash_origem: if alvo is not None and alvo.hash_origem == hash_origem:
return 'em dia', None # idempotência: nada mudou desde a geração return 'em dia', None # idempotência: nada mudou desde a geração
# A passada de recuperação não retifica — e decide isso ANTES de gastar
# a conversão, não depois. Alvo defasado é um problema; apagar
# assinatura já feita, em lote e sem ninguém pedir, é um problema pior e
# irreversível. A decisão de zerar (§5.1) foi tomada para o ato isolado
# de retificar um texto, não para uma varredura de acervo inteiro.
if alvo is not None and somente_novos:
logger.info(
'materializar_pdfs: matéria %s precisa de retificação — ADIADA '
'por --somente-novos (sem conversão)', materia.pk)
return 'adiado', None
# Reuso da rotina da sprint: PDF copia os bytes, DOCX converte no # Reuso da rotina da sprint: PDF copia os bytes, DOCX converte no
# OnlyOffice — a conversão acontece UMA vez, aqui, fora do caminho # OnlyOffice — a conversão acontece UMA vez, aqui, fora do caminho
# quente das requisições (§5.1). # quente das requisições (§5.1).

100
sapl/integracao_hub/tests/test_materializacao.py

@ -183,7 +183,7 @@ def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch):
passadas = [] passadas = []
def passada_que_explode(self): def passada_que_explode(self, somente_novos=False):
passadas.append(1) passadas.append(1)
raise RuntimeError('banco caiu no meio da varredura') raise RuntimeError('banco caiu no meio da varredura')
@ -402,3 +402,101 @@ def test_pdf_nao_passa_pela_conferencia_de_url(db, monkeypatch):
call_command('materializar_pdfs_para_assinatura') call_command('materializar_pdfs_para_assinatura')
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists() assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()
# ---------------------------------------------------------------------------
# --somente-novos: a passada de recuperação não pode apagar assinatura
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_somente_novos_gera_o_alvo_ausente(db):
"""É para isso que o modo existe: o acervo que nunca materializou.
O dia em que a conversão DOCX voltar a funcionar, 861 matérias (Franco,
22/08/2026) materializam de uma vez. Gerar o que falta é exatamente o que
se quer dessa passada.
"""
materia = criar_materia(protocolo=300)
call_command('materializar_pdfs_para_assinatura', somente_novos=True)
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()
@pytest.mark.django_db(transaction=False)
def test_somente_novos_adia_retificacao_e_preserva_assinatura(db):
"""A varredura não pode zerar assinatura por efeito colateral.
Zerar em retificação (§5.1) foi decidido para o ato isolado de retificar um
texto quem retifica sabe o que está desfazendo. Numa passada sobre o
acervo inteiro ninguém pediu isso, e apagar assinatura é irreversível.
"""
materia = criar_materia(protocolo=301)
call_command('materializar_pdfs_para_assinatura')
alvo_antes = DocumentoParaAssinatura.objects.get(materia=materia)
usuario = baker.make('auth.User', username='ver-b')
materia.refresh_from_db()
materia.pdf_assinado.save(
'materia_%s_assinado_1.pdf' % materia.pk,
ContentFile(b'%PDF-assinado'), save=False)
materia.assinatura_info = [{'signed_by': 'ver-b', 'nome': 'Ver. B'}]
materia.assinado_em = timezone.now()
materia.assinado_por = usuario
materia.codigo_autenticacao = 'EFGH5678EFGH5678'
materia.save()
materia.texto_original.save(
'texto.pdf', ContentFile(b'%PDF-1.4 retificado'), save=True)
call_command('materializar_pdfs_para_assinatura', somente_novos=True)
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
assert alvo.hash_origem == alvo_antes.hash_origem # alvo intocado
materia.refresh_from_db()
assert materia.pdf_assinado
assert materia.assinatura_info == [{'signed_by': 'ver-b', 'nome': 'Ver. B'}]
assert materia.codigo_autenticacao == 'EFGH5678EFGH5678'
@pytest.mark.django_db(transaction=False)
def test_somente_novos_decide_antes_de_gastar_a_conversao(db, monkeypatch):
"""Adiar depois de converter seria pagar o OnlyOffice para jogar fora.
Numa passada de recuperação sobre um acervo grande isso é a diferença entre
minutos e horas de conversão desperdiçada.
"""
materia = criar_materia(protocolo=302, nome='a.docx', conteudo=b'docx-v1')
monkeypatch.setattr(
MOD + '.http_requests.get',
lambda url, timeout: _RespostaFalsa(b'docx-v1'))
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda m, r: (PDF, None))
call_command('materializar_pdfs_para_assinatura')
materia.texto_original.save('a.docx', ContentFile(b'docx-v2'), save=True)
def nao_deveria_converter(*args, **kwargs):
raise AssertionError('conversão gasta em matéria que seria adiada')
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
nao_deveria_converter)
monkeypatch.setattr(MOD + '.http_requests.get', nao_deveria_converter)
call_command('materializar_pdfs_para_assinatura', somente_novos=True)
@pytest.mark.django_db(transaction=False)
def test_adiada_aparece_no_resumo(db, capsys):
"""Adiada não é "em dia" — trabalho pendente calado vira surpresa depois."""
materia = criar_materia(protocolo=303)
call_command('materializar_pdfs_para_assinatura')
materia.texto_original.save(
'texto.pdf', ContentFile(b'%PDF-1.4 outro'), save=True)
capsys.readouterr()
call_command('materializar_pdfs_para_assinatura', somente_novos=True)
assert 'ADIADA(S) por --somente-novos' in capsys.readouterr().out

Loading…
Cancel
Save