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>
Plain asyncio.gather on one event loop could let the first upload commit
before the second's pre-check, making the second a legitimate replace (2
successes, 1 live row) -- correct behavior that fails the '1 conflict'
assertion. Same Event-synchronization pattern as the auto's HTTP race tests:
hold the first past its pre-check until the second also pre-checks, so both
read None before either writes and the partial-unique index decides
deterministically. Code under test untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port certificate lifecycle (parse/encrypt/upload/deactivate) and
FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref,
branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series
next_number regression check that Task 3 deferred to this task.
Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.