diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py index 77d78e829..ecd7a6ce5 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/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,20 +169,36 @@ 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', ''), - _base_url_de_sistema())) - self.stderr.write(aviso) - logger.error(aviso) + # 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', '')) + self.stderr.write(aviso) + logger.error(aviso) def _materializar(self, materia): materia.texto_original.open('rb') @@ -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 diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py index 64d9a08be..43369f9c5 100644 --- a/sapl/integracao_hub/tests/test_materializacao.py +++ b/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()