feat: tenancy — products + API keys

Product is the only tenant table this service owns (porte table:
organization_id -> product_id, everything below it -- tenant_ref/
branch_ref -- stays an opaque string owned by the consuming product, never
a row here). Bcrypt-hashed API keys (passlib, same CryptContext shape as
the auto's auth.service), generated once by scripts/create_product.py and
never persisted in the clear. require_product (X-Api-Key -> Product, 401
on missing/wrong/soft-deleted) is the porte adaptation of the auto's
require_permission — every /v1/* route will depend on it instead of a JWT
bearer token.

Also ports shared/base_model.py and shared/errors.py verbatim (Global
Constraints: soft delete mixins, structured 409 bodies).

products migration + real "alembic upgrade head" round-trip test
(tests/migrations/, new shared _helpers.py instead of the auto's ad hoc
cross-file _run_psql reuse). 13 tests green via `make k8s-test`.
This commit is contained in:
jonatanritter
2026-07-22 16:18:25 -03:00
parent 923848af33
commit 1791435a8d
15 changed files with 522 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
"""Migration test (Task 2): `products` -- confirms the table/columns exist
via `information_schema` over the `create_all` schema (Teste A, same
precedent as the auto's `test_fiscal_cadastro_schema.py`), and that a
`Product` round-trips (INSERT cru via the real ORM/service) on a database
built PURELY by `alembic upgrade head` (Teste B) -- never
`Base.metadata.create_all`, which would never catch the migration itself
drifting from the model."""
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# Import de registro: garante que Product está registrado em Base.metadata
# antes do create_all da fixture test_engine, ao rodar este arquivo isolado.
from fiscal_svc.tenancy import models as _tenancy_models # noqa: F401
from tests.migrations._helpers import run_alembic, run_psql
_MIGRATION_DB_NAME = "fiscal_svc_test_products_schema"
_MIGRATION_DB_URL = (
f"postgresql+asyncpg://postgres:postgres@localhost:5432/{_MIGRATION_DB_NAME}"
)
@pytest.mark.asyncio
async def test_products_table_and_columns_exist(db_session):
rows = {
r[0]: r[1]
for r in (
await db_session.execute(
text(
"select column_name, is_nullable from information_schema.columns "
"where table_name='products'"
)
)
)
}
for required in ("id", "name", "api_key_hash", "webhook_secret", "created_at", "deleted_at"):
assert required in rows, f"coluna {required} ausente em products"
assert rows["name"] == "NO"
assert rows["api_key_hash"] == "NO"
assert rows["webhook_secret"] == "YES"
assert rows["deleted_at"] == "YES"
@pytest_asyncio.fixture
async def migration_database():
run_psql("-c", f"DROP DATABASE IF EXISTS {_MIGRATION_DB_NAME} WITH (FORCE);")
run_psql("-c", f"CREATE DATABASE {_MIGRATION_DB_NAME};")
yield
run_psql("-c", f"DROP DATABASE IF EXISTS {_MIGRATION_DB_NAME} WITH (FORCE);")
@pytest.mark.asyncio
async def test_product_round_trips_on_a_real_migrated_database(migration_database):
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.tenancy.service import create_product
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 create_product(session, name="auto", api_key="plain-key-123")
product_id = product.id
# The plaintext key is NEVER what lands in the column.
assert product.api_key_hash != "plain-key-123"
async with session_maker() as session:
from fiscal_svc.tenancy.models import Product
reloaded = await session.get(Product, product_id)
assert reloaded.name == "auto"
assert reloaded.deleted_at is None
result_q = await session.execute(select(Product).where(Product.name == "auto"))
assert result_q.scalar_one().id == product_id
finally:
await engine.dispose()