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
@@ -231,13 +231,16 @@ async def test_chave_acesso_unique_constraint_holds_on_real_migration(migration_
@pytest.mark.asyncio
async def test_two_live_certificates_for_same_product_branch_violate_unique_index_on_real_migration(
async def test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration(
migration_database,
):
"""Same fix/reasoning as the auto's `a1b2c3d4e5f6` migration: a PARTIAL
UNIQUE index (here on `(product_id, branch_ref) WHERE deleted_at IS
NULL`) makes two concurrently-uploaded LIVE certificates for the same
slot structurally impossible, not just avoided by the service layer."""
UNIQUE index (here on `(product_id, tenant_ref, branch_ref) WHERE
deleted_at IS NULL` -- FIX 2/F2 review, migration `8f1a2c9d4b6e`) makes
two concurrently-uploaded LIVE certificates for the SAME
`(product_id, tenant_ref, branch_ref)` slot structurally impossible, not
just avoided by the service layer. Renamed from `..._product_branch_...`
(pre-FIX-2 name) -- `tenant_ref` is now PART of the scope this proves."""
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
@@ -274,6 +277,64 @@ async def test_two_live_certificates_for_same_product_branch_violate_unique_inde
await engine.dispose()
@pytest.mark.asyncio
async def test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration(
migration_database,
):
"""FIX 2 (F2 review): the pre-fix index was `(product_id, branch_ref)
WHERE deleted_at IS NULL` -- ONE live cert per `branch_ref` PER PRODUCT,
regardless of tenant. Two tenants of the SAME product reusing the
identical opaque `branch_ref="matriz"` collided on that slot: this
proves, on a database built PURELY by `alembic upgrade head`, that BOTH
now insert and stay live simultaneously -- the index scope is
`(product_id, tenant_ref, branch_ref)`."""
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.documents.models import FiscalCertificate
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
try:
async with session_maker() as session:
product = await _make_product(session, name="auto-cert-multi-tenant")
def _cert(tenant_ref, cnpj):
return FiscalCertificate(
product_id=product.id,
tenant_ref=tenant_ref,
branch_ref="matriz",
cnpj=cnpj,
pfx_encrypted=b"\x00pfx",
password_encrypted=b"\x00pw",
subject_cn=f"EMPRESA TESTE LTDA:{cnpj}",
cnpj_certificado=cnpj,
not_valid_before=datetime.now(timezone.utc) - timedelta(days=1),
not_valid_after=datetime.now(timezone.utc) + timedelta(days=365),
)
cert_a = _cert("tenant-a", "14200166000280")
cert_b = _cert("tenant-b", "99887766000155")
session.add(cert_a)
session.add(cert_b)
# Would raise IntegrityError on the old (product_id, branch_ref)
# index before FIX 2 -- the second INSERT collided with the
# first tenant's still-live row.
await session.commit()
cert_a_id, cert_b_id = cert_a.id, cert_b.id
async with session_maker() as session:
reloaded_a = await session.get(FiscalCertificate, cert_a_id)
reloaded_b = await session.get(FiscalCertificate, cert_b_id)
assert reloaded_a.deleted_at is None
assert reloaded_b.deleted_at is None
assert reloaded_a.cnpj_certificado == "14200166000280"
assert reloaded_b.cnpj_certificado == "99887766000155"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_duplicate_fiscal_series_tuple_violates_unique_constraint_on_real_migration(
migration_database,