Browse Source

Fix(Assinatura): PDF que nao encadeia e recusado, nao sobrescreve AB#1473 (#24)

Assinar pelo app uma materia ja assinada no SAPL apagava a assinatura anterior.
Sem erro nenhum: o `assinatura_info` seguia dizendo duas, e o PDF tinha uma.

Por que acontece. O encadeamento do amu-backend (ADR-0013) procura a pendencia
irma ASSINADA no banco DELE. Assinatura feita no SAPL nao cria irma la — ela
chega pelo evento `DocumentoAssinado`, e ate ele chegar o app recai no alvo em
branco. O micro entao ve zero assinaturas, compoe uma pagina nova, e o receiver
daqui SOBRESCREVE o `pdf_assinado` com esse PDF de uma assinatura so.

Com dado real (IND 820/2026): Kinho assinou as 16:01, o cursor de
assinaturas-concluidas do hub estava em 15:01 e o processo, parado. As duas
pendencias no AMU seguiam PENDENTE com `assinadoDocumentoKey` nulo. A janela
nao e teorica.

O que muda aqui:

1. O receiver conta as assinaturas no BINARIO que chega e compara com as
   registradas. Um PDF que substitui N assinaturas precisa trazer pelo menos
   N+1; menos que isso e fork, nao encadeamento → 409 e nada e gravado. A
   pendencia continua de pe e o app reassina sobre o documento certo, que e o
   desfecho correto. Perder assinatura em silencio nao e.

   A conta que decide e a do artefato, nao a do banco: `assinatura_info` e o
   que o SAPL ACHA que o documento tem, o PDF e o que ele TEM. Contagem
   impossivel (PDF ilegivel) nao vira recusa — so barra o que PROVA o fork.

2. A pendencia passa a se descrever inteira: `assinaturas_existentes`,
   `codigo_autenticacao` e `documento_encadeado` (URL + hash do pdf_assinado
   corrente). Assim o consumidor acerta sem depender de ter processado o evento
   anterior. `documento` continua sendo o ALVO BASE de proposito — e contra ele
   que o receiver confere `hash_alvo_esperado` na retificacao (§5.1). Campos
   aditivos: consumidor antigo ignora e segue como antes.

O par desta correcao esta no amu-backend (`assinaturaEncadeada` passa a olhar a
propria pendencia). Emenda registrada na ADR-0013.

De quebra, a assercao obsoleta em `test_grava_assinatura_no_formato_da_sprint`:
ela cobrava `info['nome']`, chave que o receiver deixou de gravar em 93af2c45
quando passou a usar as chaves da rotina nativa (ADR-0013 §4). O teste estava
vermelho desde entao.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
efecb24e76
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 26
      sapl/integracao_hub/serializacao.py
  2. 137
      sapl/integracao_hub/tests/test_assinatura.py
  3. 47
      sapl/integracao_hub/views.py
  4. 29
      sapl/materia/views_assinatura.py

26
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(),

137
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

47
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,

29
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,

Loading…
Cancel
Save