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`.
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
"""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)
|