fix(certificates): scope the live certificate uniquely per tenant, not just per branch_ref

IMPORTANT (F2 review): certificate is per-tenant, matching series (already
tenant-scoped) and the GET/DELETE anti-oracle boundary. Emission's
_get_live_certificate and the upload-replace pre-check (certificates.
service, renamed _get_live_certificate_by_branch ->
_get_live_certificate_by_tenant_branch) both omitted tenant_ref -- two
tenants of one product reusing branch_ref="matriz" collapsed onto the same
slot: B's upload soft-deleted A's still-live certificate, and A's emission
went on to sign with B's certificate.

Migration 8f1a2c9d4b6e replaces the partial-unique index
ix_fiscal_certificates_product_branch_live with
ix_fiscal_certificates_product_tenant_branch_live on
(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL, with a
working downgrade. Upload's two-layer defense (pre-check + IntegrityError ->
CertificateUploadConflictError) still holds against the new index.

Tests:
- tests/emission/test_emissao.py::
  test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados
  -- two tenants upload for the same product/branch_ref, both stay live;
  emission for each signs with its OWN certificate (observable via FIX 1's
  CNPJ check: without FIX 2, tenant A's emission would 409
  emitente_certificate_cnpj_mismatch because the "live" cert would
  actually be B's).
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration
  -- real alembic upgrade head, raw INSERTs proving both tenants' certs
  land live.
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration
  (renamed from ..._product_branch_...) -- same (product, tenant, branch)
  still rejects a second live certificate on the real migration.
- tests/certificates/test_certificates.py::
  test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409
  updated for the renamed/re-scoped precheck function (still same-tenant
  race, still 1 winner + 1 CertificateUploadConflictError).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 15:32:20 -03:00
co-authored by Claude Opus 4.8
parent 3e3a1abc6f
commit 72bb089222
7 changed files with 272 additions and 60 deletions
+12 -10
View File
@@ -355,11 +355,11 @@ async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_othe
Sem sincronização explícita, as duas corrotinas rodam no MESMO event
loop e podem interleavear de um jeito que NÃO exercita a corrida real:
se a primeira `upload_certificate` COMMITA inteiro antes de a segunda
fazer o pre-check `_get_live_certificate_by_branch`, a segunda enxerga a
linha viva da primeira e faz um REPLACE LEGÍTIMO (soft-delete + insert)
-- 2 sucessos, 1 linha viva, comportamento CORRETO do serviço, mas que
quebraria a asserção abaixo (que exige exatamente 1 sucesso + 1
conflito). Mesma técnica de sincronização determinística de
fazer o pre-check `_get_live_certificate_by_tenant_branch`, a segunda
enxerga a linha viva da primeira e faz um REPLACE LEGÍTIMO (soft-delete
+ insert) -- 2 sucessos, 1 linha viva, comportamento CORRETO do
serviço, mas que quebraria a asserção abaixo (que exige exatamente 1
sucesso + 1 conflito). Mesma técnica de sincronização determinística de
`auto/backend/tests/modules/financeiro/test_pay_account_payable.py::
test_pay_concurrent_with_cancel_via_http_lock_serializes_the_race`:
monkeypatch no ponto de await entre o pre-check e o commit, com um
@@ -373,15 +373,15 @@ async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_othe
pfx_a = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="A:14200166000187")
pfx_b = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="B:14200166000187")
original_precheck = certificate_service._get_live_certificate_by_branch
original_precheck = certificate_service._get_live_certificate_by_tenant_branch
precheck_done = asyncio.Event()
first_precheck_claimed = False
async def _precheck_forcing_both_before_any_commit(session, product_id, branch_ref):
async def _precheck_forcing_both_before_any_commit(session, product_id, tenant_ref, branch_ref):
nonlocal first_precheck_claimed
if not first_precheck_claimed:
first_precheck_claimed = True
result = await original_precheck(session, product_id, branch_ref)
result = await original_precheck(session, product_id, tenant_ref, branch_ref)
precheck_done.set()
# Segura ESTA chamada (ainda antes do commit em upload_certificate)
# até depois que a outra também tenha feito seu pre-check --
@@ -390,10 +390,12 @@ async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_othe
await asyncio.sleep(0.3)
return result
await precheck_done.wait()
return await original_precheck(session, product_id, branch_ref)
return await original_precheck(session, product_id, tenant_ref, branch_ref)
monkeypatch.setattr(
certificate_service, "_get_live_certificate_by_branch", _precheck_forcing_both_before_any_commit
certificate_service,
"_get_live_certificate_by_tenant_branch",
_precheck_forcing_both_before_any_commit,
)
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)