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
@@ -0,0 +1,53 @@
|
||||
"""fiscal_certificates: partial-unique live index scoped by tenant_ref too
|
||||
(FIX 2, F2 review, 2026-07-17-sowai-fiscal-svc-design.md decisão #4) --
|
||||
`ix_fiscal_certificates_product_branch_live` (`(product_id, branch_ref)
|
||||
WHERE deleted_at IS NULL`) let two DIFFERENT tenants of the SAME product
|
||||
reusing an identical opaque `branch_ref` (e.g. both `"matriz"`) collapse
|
||||
onto the SAME certificate slot: the second tenant's upload soft-deleted the
|
||||
first tenant's still-live certificate as a legitimate "replace" instead of
|
||||
a 409 conflict, and emission for the first tenant would go on to sign with
|
||||
the second tenant's certificate. Replaces the index with one scoped
|
||||
`(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL` -- matching
|
||||
`fiscal_series`'s own tenant-scoped uniqueness and the GET/DELETE
|
||||
certificate lookups, which already filtered by `tenant_ref`.
|
||||
|
||||
Revision ID: 8f1a2c9d4b6e
|
||||
Revises: 30a80fe36910
|
||||
Create Date: 2026-07-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "8f1a2c9d4b6e"
|
||||
down_revision: Union[str, Sequence[str], None] = "30a80fe36910"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_fiscal_certificates_product_branch_live", table_name="fiscal_certificates")
|
||||
op.create_index(
|
||||
"ix_fiscal_certificates_product_tenant_branch_live",
|
||||
"fiscal_certificates",
|
||||
["product_id", "tenant_ref", "branch_ref"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_fiscal_certificates_product_tenant_branch_live", table_name="fiscal_certificates"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_fiscal_certificates_product_branch_live",
|
||||
"fiscal_certificates",
|
||||
["product_id", "branch_ref"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
Reference in New Issue
Block a user