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:
co-authored by
Claude Opus 4.8
parent
3e3a1abc6f
commit
72bb089222
@@ -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)
|
||||
|
||||
@@ -410,6 +410,71 @@ async def test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero
|
||||
assert success_response.json()["numero"] == raw["numero"]
|
||||
|
||||
|
||||
# --- FIX 2 (F2 review): certificado é POR TENANT, não só por branch_ref -----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados(db_session):
|
||||
"""Antes do FIX 2, `_get_live_certificate` (emissão) e o pre-check de
|
||||
upload omitiam `tenant_ref` -- dois tenants do MESMO produto reusando o
|
||||
MESMO `branch_ref` opaco ("matriz", plausível: refs são strings livres
|
||||
do produto chamador) colapsavam no MESMO slot. O upload do tenant B
|
||||
soft-deletava o certificado ainda vivo do tenant A (replace
|
||||
"legítimo"), e a emissão do tenant A passava a resolver o certificado
|
||||
de B.
|
||||
|
||||
A prova combina FIX 1 (CNPJ do emitente vs certificado) para tornar o
|
||||
vínculo OBSERVÁVEL: sem o FIX 2, o certificado "vivo" para `branch_ref
|
||||
="matriz"` seria o de B (CNPJ_B) para AMBOS os tenants -- a emissão do
|
||||
tenant A com `emitente.cnpj`=CNPJ_A bateria no FIX 1 e devolveria 409
|
||||
`emitente_certificate_cnpj_mismatch` em vez de 201."""
|
||||
cnpj_a = "11222333000181"
|
||||
cnpj_b = "44555666000107"
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload_a, raw_a = _payload_from_golden(
|
||||
"caso_padrao_intra", tenant_ref="tenant-a", branch_ref="matriz"
|
||||
)
|
||||
payload_a["emitente"] = {**payload_a["emitente"], "cnpj": cnpj_a}
|
||||
payload_b, raw_b = _payload_from_golden(
|
||||
"caso_padrao_inter", tenant_ref="tenant-b", branch_ref="matriz"
|
||||
)
|
||||
payload_b["emitente"] = {**payload_b["emitente"], "cnpj": cnpj_b}
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _setup_certificate_and_series(
|
||||
db_session, client, key, tenant_ref="tenant-a", branch_ref="matriz",
|
||||
serie=raw_a["serie"], next_number=raw_a["numero"], cnpj=cnpj_a,
|
||||
)
|
||||
# Segundo upload, MESMO produto, MESMO branch_ref, tenant DIFERENTE.
|
||||
await _setup_certificate_and_series(
|
||||
db_session, client, key, tenant_ref="tenant-b", branch_ref="matriz",
|
||||
serie=raw_b["serie"], next_number=raw_b["numero"], cnpj=cnpj_b,
|
||||
)
|
||||
|
||||
# O certificado de A segue vivo (GET de A não foi soft-deletado
|
||||
# pelo upload de B) -- prova direta, sem depender do FIX 1.
|
||||
get_a = await client.get(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "tenant-a", "branch_ref": "matriz"},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert get_a.status_code == 200, get_a.text
|
||||
assert get_a.json()["cnpj_certificado"] == cnpj_a
|
||||
|
||||
emit_a = await client.post(
|
||||
"/v1/emissoes", json=payload_a,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-a-{uuid.uuid4().hex}"},
|
||||
)
|
||||
emit_b = await client.post(
|
||||
"/v1/emissoes", json=payload_b,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-b-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert emit_a.status_code == 201, emit_a.text
|
||||
assert emit_b.status_code == 201, emit_b.text
|
||||
|
||||
|
||||
# --- prova do outbox --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user