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:
@@ -0,0 +1,49 @@
|
||||
"""Shared plumbing for the "real migration" tests (Global Constraints:
|
||||
every migration ships with a test that runs `alembic upgrade head` as a
|
||||
REAL subprocess against a disposable database -- never just
|
||||
`Base.metadata.create_all`, which builds the schema straight from the
|
||||
current model definitions and so can never catch a migration that drifted
|
||||
from them). Mirrors the technique `auto/backend/tests/migrations/` uses
|
||||
(e.g. `test_seed_cadastros_peca_pessoa_editar_permission.py`'s `_run_psql`),
|
||||
centralized here instead of re-imported test-file-to-test-file -- this repo
|
||||
starts that convention fresh rather than porting the auto's ad hoc
|
||||
cross-file reuse."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
_PSQL = "psql"
|
||||
|
||||
|
||||
def run_psql(*args: str) -> subprocess.CompletedProcess:
|
||||
"""Runs `psql -U postgres -h localhost <args>` against the pod's
|
||||
Postgres sidecar (trust auth -- see `auto/k8s/test-runner.yaml`'s
|
||||
header comment for why localhost/no-password is assumed) and asserts
|
||||
it succeeded."""
|
||||
result = subprocess.run(
|
||||
[_PSQL, "-U", "postgres", "-h", "localhost", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"psql failed: {result.stderr}"
|
||||
return result
|
||||
|
||||
|
||||
def run_alembic(database_url: str, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Runs `uv run alembic <args>` as a subprocess with `DATABASE_URL`
|
||||
pointed at `database_url` (never the service's own `settings.
|
||||
database_url` default) -- `alembic/env.py` reads `DATABASE_URL` via
|
||||
`fiscal_svc.core.config.settings`, which layers real env vars over
|
||||
`.env`."""
|
||||
env = {**os.environ, "DATABASE_URL": database_url}
|
||||
return subprocess.run(
|
||||
["uv", "run", "alembic", *args],
|
||||
cwd=str(REPO_DIR),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Task 2: `Product` tenancy -- API key hashing/verification
|
||||
(`tenancy.service`) and the `require_product` FastAPI dependency
|
||||
(`tenancy.deps`), the adaptation of the auto's `require_permission(...)`
|
||||
(porte table: API key -> Product, no JWT/user here).
|
||||
|
||||
Every key below is generated with a `uuid4` suffix (`_key()`), never a
|
||||
fixed literal: `products` has NO tenant scoping to isolate one test's rows
|
||||
from another's (unlike the auto's org-scoped tables, where a fixed literal
|
||||
is safe because every query also filters by that test's own
|
||||
`organization_id`) -- `authenticate_product` deliberately scans ALL live
|
||||
products (see its docstring), so a fixed key reused across two tests would
|
||||
have the OLDER, still-committed product (this suite's `db_session` fixture
|
||||
only rolls back; it does not truncate what earlier tests committed) answer
|
||||
for the newer test's assertions instead of a genuine 404/401."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from fiscal_svc.tenancy.deps import require_product
|
||||
from fiscal_svc.tenancy.service import authenticate_product, create_product, hash_api_key
|
||||
|
||||
|
||||
def _key() -> str:
|
||||
return f"k-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_product_never_persists_the_plaintext_key(db_session):
|
||||
key = _key()
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
assert product.api_key_hash != key
|
||||
# bcrypt's own marker -- proves passlib actually hashed it, not just
|
||||
# stored some other opaque transform.
|
||||
assert product.api_key_hash.startswith("$2b$")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_product_returns_product_for_the_correct_key(db_session):
|
||||
key_a, key_b = _key(), _key()
|
||||
product = await create_product(db_session, name="auto", api_key=key_a)
|
||||
await create_product(db_session, name="crm", api_key=key_b)
|
||||
|
||||
found = await authenticate_product(db_session, key_a)
|
||||
|
||||
assert found is not None
|
||||
assert found.id == product.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_product_returns_none_for_a_wrong_key(db_session):
|
||||
await create_product(db_session, name="auto", api_key=_key())
|
||||
|
||||
assert await authenticate_product(db_session, _key()) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_product_ignores_soft_deleted_products(db_session):
|
||||
"""A revoked/deactivated Product's key must stop authenticating --
|
||||
same 'revoked = gone' semantics as the auto's `get_active_user` ignoring
|
||||
a soft-deleted User."""
|
||||
key = _key()
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
product.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
assert await authenticate_product(db_session, key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_product_distinguishes_products_with_similar_keys(db_session):
|
||||
"""Not a narrowed lookup (no identifier besides the key itself) --
|
||||
proves the "verify against every live product" loop in
|
||||
`authenticate_product` returns the RIGHT product, not just A product."""
|
||||
key_a, key_b = _key(), _key()
|
||||
product_a = await create_product(db_session, name="auto", api_key=key_a)
|
||||
product_b = await create_product(db_session, name="crm", api_key=key_b)
|
||||
|
||||
found_a = await authenticate_product(db_session, key_a)
|
||||
found_b = await authenticate_product(db_session, key_b)
|
||||
|
||||
assert found_a.id == product_a.id
|
||||
assert found_b.id == product_b.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_product_missing_header_is_401(db_session):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_product(x_api_key=None, session=db_session)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_product_wrong_key_is_401(db_session):
|
||||
key = _key()
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_product(x_api_key=f"not-{key}", session=db_session)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_product_soft_deleted_product_is_401(db_session):
|
||||
key = _key()
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
product.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_product(x_api_key=key, session=db_session)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_product_valid_key_returns_the_product(db_session):
|
||||
key = _key()
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
result = await require_product(x_api_key=key, session=db_session)
|
||||
|
||||
assert result.id == product.id
|
||||
|
||||
|
||||
def test_hash_api_key_is_salted_non_deterministic():
|
||||
"""Same input, two different hashes -- this is EXACTLY why
|
||||
`authenticate_product` cannot do `WHERE api_key_hash = hash(candidate)`
|
||||
and must verify against each live product's hash instead (see its
|
||||
docstring)."""
|
||||
same_input = _key()
|
||||
assert hash_api_key(same_input) != hash_api_key(same_input)
|
||||
Reference in New Issue
Block a user