diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index 0e708328d..17ceb6226 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/sapl/integracao_hub/serializacao.py @@ -187,6 +187,32 @@ def serializar_pendencia(alvo, request): 'documento': _bloco_documento( alvo.arquivo, request, 'integracao_hub_documento_alvo', materia.pk, hash_sha256=alvo.hash_sha256), + # ── Estado da cadeia (ADR-0013, extensao entre sistemas) ───────────── + # + # `documento` acima e sempre o ALVO BASE — e tem que continuar sendo: + # e contra ele que o receiver confere `hash_alvo_esperado` na checagem + # de retificacao (§5.1). Mas quando a materia JA TEM assinatura, o alvo + # nao e o que se deve assinar agora: assinar o alvo em branco produz um + # PDF que substitui as assinaturas anteriores em vez de somar. + # + # O encadeamento do amu-backend resolve isso olhando a pendencia irma + # ASSINADA no banco DELE — o que so funciona se a assinatura anterior + # tambem tiver sido feita pelo app, ou se o `DocumentoAssinado` da + # assinatura feita AQUI ja tiver chegado la. Assinatura feita no SAPL + # com o poll atrasado cai fora das duas hipoteses. + # + # Por isso a pendencia passa a se descrever inteira: quantas assinaturas + # ja existem, qual documento encadear e com que codigo. O consumidor + # nao precisa ter visto o evento anterior para acertar — e um consumidor + # antigo ignora os campos novos e segue como antes. + 'assinaturas_existentes': [ + a.get('signed_by') + for a in _normalizar_assinatura_info(materia.assinatura_info)], + 'codigo_autenticacao': materia.codigo_autenticacao or None, + 'documento_encadeado': _bloco_documento( + materia.pdf_assinado, request, + 'integracao_hub_documento_assinado', materia.pk + ) if materia.pdf_assinado else None, 'verification_url_base': _construir_url_verificacao_base( request, 'materia', materia.pk), 'casa_legislativa': _obter_nome_casa_legislativa(), diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py index b24d598a8..7c5385656 100644 --- a/sapl/integracao_hub/tests/test_assinatura.py +++ b/sapl/integracao_hub/tests/test_assinatura.py @@ -307,7 +307,13 @@ def test_grava_assinatura_no_formato_da_sprint(cliente_hub, materia_pronta): assert materia.pdf_assinado.read() == PDF_ASSINADO info = materia.assinatura_info[0] assert info['signed_by'] == 'ver-a' # username do OperadorAutor do autor - assert info['nome'] == 'Vereador ver-a' + # `nome_assinante`/`cargo`/`data_assinatura` sao as chaves da rotina NATIVA + # do SAPL, que a verificacao publica le (ADR-0013 §4). O receiver gravava + # `nome`/`data` e a via do app entrava sem nome na tela de verificacao — + # esta assercao ficou para tras quando isso foi corrigido em 93af2c45. + assert info['nome_assinante'] == 'Vereador ver-a' + assert info['cargo'] + assert info['data_assinatura'] assert info['tipo_certificado'] == 'A1' assert materia.assinado_em is not None # Primeira assinatura gera o código público, como no fluxo local. @@ -706,3 +712,132 @@ def test_desde_invalido_e_400(cliente_hub): {'desde': 'ontem', 'id_gt': 0}) assert resposta.status_code == 400 + + +# --------------------------------------------------------------------------- +# Guarda de encadeamento (ADR-0013 entre sistemas) — nao perder assinatura +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_pdf_que_nao_encadeia_e_recusado_sem_apagar_assinatura( + cliente_hub, materia_pronta, monkeypatch): + """App assinou o alvo em branco com a materia ja assinada aqui: 409, nada muda. + + E o desfecho da janela em que o `DocumentoAssinado` da assinatura feita no + SAPL ainda nao chegou ao app. Antes desta guarda o receiver sobrescrevia o + `pdf_assinado` e a assinatura anterior sumia do documento — com o + `assinatura_info` continuando a dizer que ela existia. + """ + from sapl.materia import views_assinatura + + materia, autor = materia_pronta + materia.assinatura_info = [{'signed_by': 'outro-vereador', + 'nome_assinante': 'OUTRO'}] + materia.pdf_assinado.save('ja_assinado.pdf', ContentFile(b'%PDF ja assinado'), + save=False) + materia.codigo_autenticacao = 'ABCDEF0123456789' + materia.save() + pdf_anterior = materia.pdf_assinado.name + + # O binario que chega traz UMA assinatura — nao encadeou com a que existe. + monkeypatch.setattr(views_assinatura, 'contar_assinaturas_no_pdf', + lambda _b: 1) + + resposta = cliente_hub.post( + URL_ASSINATURAS, corpo_assinatura(materia, autor), format='multipart') + + assert resposta.status_code == 409 + assert resposta.data['assinaturas_registradas'] == 1 + assert resposta.data['assinaturas_no_pdf'] == 1 + assert resposta.data['codigo_autenticacao'] == 'ABCDEF0123456789' + assert resposta.data['documento_corrente'].endswith('/assinado/') + + materia.refresh_from_db() + assert materia.pdf_assinado.name == pdf_anterior # nao sobrescreveu + assert len(materia.assinatura_info) == 1 # nao apendou + assert materia.assinatura_info[0]['signed_by'] == 'outro-vereador' + + +@pytest.mark.django_db(transaction=False) +def test_pdf_encadeado_passa_e_soma_a_assinatura( + cliente_hub, materia_pronta, monkeypatch): + from sapl.materia import views_assinatura + + materia, autor = materia_pronta + materia.assinatura_info = [{'signed_by': 'outro-vereador'}] + materia.pdf_assinado.save('ja_assinado.pdf', ContentFile(b'%PDF ja assinado'), + save=False) + materia.save() + + # Duas assinaturas no binario para uma registrada: encadeou. + monkeypatch.setattr(views_assinatura, 'contar_assinaturas_no_pdf', + lambda _b: 2) + + resposta = cliente_hub.post( + URL_ASSINATURAS, corpo_assinatura(materia, autor), format='multipart') + + assert resposta.status_code == 201 + materia.refresh_from_db() + assert [a['signed_by'] for a in materia.assinatura_info] == \ + ['outro-vereador', 'ver-a'] + + +@pytest.mark.django_db(transaction=False) +def test_pdf_ilegivel_nao_bloqueia_a_assinatura( + cliente_hub, materia_pronta, monkeypatch): + """Duvida na contagem nao vira recusa: so PDF que PROVA o fork e barrado.""" + from sapl.materia import views_assinatura + + materia, autor = materia_pronta + materia.assinatura_info = [{'signed_by': 'outro-vereador'}] + materia.save() + monkeypatch.setattr(views_assinatura, 'contar_assinaturas_no_pdf', + lambda _b: None) + + resposta = cliente_hub.post( + URL_ASSINATURAS, corpo_assinatura(materia, autor), format='multipart') + + assert resposta.status_code == 201 + + +@pytest.mark.django_db(transaction=False) +def test_pendencia_carrega_o_estado_da_cadeia(cliente_hub): + """O app precisa saber o que encadear SEM ter visto o evento anterior.""" + materia, _ = criar_materia_com_alvo() + autor_a = criar_autor_com_operador('assinou') + autor_b = criar_autor_com_operador('falta') + baker.make(Autoria, materia=materia, autor=autor_a) + baker.make(Autoria, materia=materia, autor=autor_b) + materia.assinatura_info = [{'signed_by': 'assinou'}] + materia.pdf_assinado.save('assinado.pdf', ContentFile(PDF_ASSINADO), + save=False) + materia.codigo_autenticacao = 'CAFEBABE12345678' + materia.save() + + resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/') + item = next(i for i in resposta.data['resultados'] if i['id'] == materia.pk) + + assert item['autores_pendentes'] == [autor_b.pk] + assert item['assinaturas_existentes'] == ['assinou'] + assert item['codigo_autenticacao'] == 'CAFEBABE12345678' + # O alvo continua sendo o alvo — a checagem de retificacao depende disso. + assert item['documento']['hash_sha256'] == \ + hashlib.sha256(PDF_ALVO).hexdigest() + # E o que assinar agora vem separado, apontando para o PDF ja assinado. + assert item['documento_encadeado']['url'].endswith('/assinado/') + assert item['documento_encadeado']['hash_sha256'] == \ + hashlib.sha256(PDF_ASSINADO).hexdigest() + + +@pytest.mark.django_db(transaction=False) +def test_pendencia_sem_assinatura_nao_tem_documento_encadeado(cliente_hub): + materia, _ = criar_materia_com_alvo() + autor = criar_autor_com_operador('ninguem-assinou') + baker.make(Autoria, materia=materia, autor=autor) + + resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/') + item = next(i for i in resposta.data['resultados'] if i['id'] == materia.pk) + + assert item['assinaturas_existentes'] == [] + assert item['codigo_autenticacao'] is None + assert item['documento_encadeado'] is None diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index 63836c8ed..5d4bacc96 100644 --- a/sapl/integracao_hub/views.py +++ b/sapl/integracao_hub/views.py @@ -25,7 +25,8 @@ from sapl.utils import get_client_ip from .models import (AnexoProposicao, AssinaturaRecebida, DocumentoParaAssinatura, EventoRecebido) -from .serializacao import (resolver_titular, +from .serializacao import (_url_absoluta, + resolver_titular, serializar_materia_assinada, serializar_pendencia, serializar_proposicao, @@ -517,6 +518,47 @@ class RecepcaoAssinaturaView(IntegracaoHubView): hash_assinado = hashlib.sha256(conteudo).hexdigest() agora = timezone.now() + # ── Guarda de encadeamento (ADR-0013) ──────────────────────────────── + # + # O receiver SOBRESCREVE `pdf_assinado`. Se o app assinou o alvo em + # branco enquanto a matéria já tinha assinatura aqui, o PDF que chega + # traz UMA assinatura e apaga as anteriores — o `assinatura_info` fica + # dizendo duas, o documento tendo uma. Dado e artefato divergentes, sem + # nenhum erro na tela. + # + # Isso acontece na janela em que o `DocumentoAssinado` da assinatura + # feita no SAPL ainda não chegou ao app: sem ele, o encadeamento do + # amu-backend não acha a irmã ASSINADA e recai no alvo. A janela é real + # — hub parado, poll atrasado, cursor ainda atrás. + # + # A conta que decide não é a do banco, é a do binário: um PDF que + # substitui N assinaturas precisa trazer pelo menos N+1. Menos que isso + # é fork, não encadeamento. 409 e nada é gravado — a pendência continua + # de pé e o app reassina em cima do documento certo, que é o desfecho + # correto. Perder assinatura em silêncio não é. + from sapl.materia.views_assinatura import contar_assinaturas_no_pdf + + ja_registradas = len(self._normalizar(materia.assinatura_info)) + if ja_registradas: + no_binario = contar_assinaturas_no_pdf(conteudo) + if no_binario is not None and no_binario <= ja_registradas: + self.logger.error( + 'materia %s: PDF assinado recebido do app tem %s assinatura(s) ' + 'e a materia ja registra %s — nao encadeou, gravar apagaria ' + 'assinatura. Recusado com 409.', + materia.pk, no_binario, ja_registradas) + return Response( + {'detalhe': 'PDF assinado nao encadeia as assinaturas ja ' + 'existentes — assine sobre o documento assinado ' + 'corrente, nao sobre o alvo', + 'assinaturas_registradas': ja_registradas, + 'assinaturas_no_pdf': no_binario, + 'codigo_autenticacao': materia.codigo_autenticacao, + 'documento_corrente': _url_absoluta( + request, 'integracao_hub_documento_assinado', + materia.pk) if materia.pdf_assinado else None}, + status=status.HTTP_409_CONFLICT) + # Rastro operacional: quem DISPAROU o ato (o vereador ou um assessor # agindo por ele). Registro interno, NÃO altera a autoria. O evento do # app ainda não carrega a identidade do assessor logado; até lá recai @@ -595,6 +637,9 @@ class RecepcaoAssinaturaView(IntegracaoHubView): 'hash_assinado': recebida.hash_assinado}, status=status.HTTP_200_OK) + from sapl.materia.pendencias import invalidar_cache_pendencias + invalidar_cache_pendencias(materia=materia, user=titular) + self.logger.info( 'integracao_hub: assinatura %s gravada na matéria %s ' '(signed_by=%s, autor=%s, operado_por=%s)', chave, materia.pk, diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index 0608fb8f1..f8d419a6f 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -279,6 +279,35 @@ def _compor_pagina_auth_localmente(pdf_bytes, request, tipo_doc, pk_doc, blocos) return buf.getvalue(), codigo +def contar_assinaturas_no_pdf(pdf_bytes): + """Quantas assinaturas digitais o PDF carrega, lendo o proprio binario. + + Existe porque `assinatura_info` e o que o SAPL ACHA que o documento tem, e + o PDF e o que ele TEM de fato. Quando as duas contas divergem, alguem + assinou um binario que nao era o corrente — e o unico jeito de saber e + contar no artefato. + + Conta campos /Sig preenchidos no AcroForm. Retorna None quando o PDF nao + da para ler: quem chama decide o que fazer com a duvida, em vez de receber + um zero que parece certeza. + """ + from PyPDF4 import PdfFileReader + + try: + leitor = PdfFileReader(io.BytesIO(pdf_bytes)) + acroform = leitor.trailer['/Root'].get('/AcroForm') + if acroform is None: + return 0 + if hasattr(acroform, 'getObject'): + acroform = acroform.getObject() + campos = [c.getObject() for c in (acroform.get('/Fields') or [])] + return len([c for c in campos + if c.get('/FT') == '/Sig' and c.get('/V') is not None]) + except Exception as exc: + logger.warning(f'Nao foi possivel contar assinaturas do PDF: {exc}') + return None + + def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, assinaturas_existentes, certificado_bytes=None, senha=None,