Completitud de CRUD a escala — de "funciona con 2 tenants" a "funciona con 50"
Esta skill nace de una lección directa de saas-multitenant-architecture: el agente que auditó el aislamiento encontró 5 fugas IDOR reales, pero no comprobó que el arreglo de cada una llegara limpio hasta la respuesta HTTP — 4 de los 5 endpoints no capturaban la excepción de validación, así que el "arreglo" habría sido un 500 sin control en producción. La lección: verificar que algo está bien no es solo comprobar la lógica de negocio, es trazarla hasta el final.
Esta skill aplica esa misma disciplina a dos preguntas que "funciona con 2 tenants en un test" no responde:
- ¿Está completo? — ¿tiene el recurso todas las operaciones que necesita, y cada una las cuatro garantías (aislamiento, paginación, validación, propagación de error)?
- ¿Se sostiene a escala? — lo que pasa la prueba con 2 tenants y 2 usuarios, ¿sigue pasando con 30 tenants y 15 usuarios cada uno? Algunos bugs (un filtro que compara "distinto de mi tenant" en vez de "igual a mi tenant", un JOIN que explota combinatoriamente, un índice que falta) solo se manifiestan con volumen real.
1. La matriz de completitud — por recurso, no por endpoint
Para cada recurso del dominio (OT, turno, material, EPI...), no todos necesitan las cuatro operaciones — un evento de historial no se edita, un accidente laboral no se borra por motivos legales. Decide qué operaciones aplican y exige las cuatro garantías en cada una que exista:
| Operación | Aislamiento | Validación | Propagación de error | Extra |
|---|---|---|---|---|
| Crear | Filtra/asigna por tenant_id del usuario autenticado |
Todo id secundario del body (no solo el principal) se valida contra el tenant — ver checklist IDOR de saas-multitenant-architecture |
El service lanza, el router captura y devuelve 400/422, nunca un 500 sin manejar | — |
| Listar | WHERE tenant_id = X, nunca "trae todo y filtra en Python" |
Filtros de query params validados | — | Paginado (page+size), nunca "devuelve todo" — ver Lente 2 de backend-reviewer |
| Leer uno | 404 si el recurso existe pero es de otro tenant (nunca 403 — no reveles que existe) | — | — | — |
| Editar | Recurso principal Y todo id secundario del body, contra el tenant | Update parcial: un campo ausente y uno enviado null se tratan igual, no se pisa nada por accidente |
Igual que crear | — |
| Borrar | Filtra por tenant antes de borrar | — | 404 si no es tuyo | Idempotencia decidida a propósito (¿borrar dos veces es 404 la segunda vez, o 204 silencioso?) |
Cómo usar la tabla: por cada recurso del backend, marca qué operaciones tiene y pasa cada fila que aplique. Si falta una garantía, es un hallazgo — con el mismo formato que backend-reviewer (archivo:línea, riesgo, arreglo).
2. El fixture a escala — de 2 tenants a N
El patrón de testing-multitenant.md (dos tenants reales, prueba explícita de que uno no lee al otro) es correcto pero insuficiente por sí solo: prueba aislamiento, no prueba que el aislamiento se sostenga cuando hay mucho más que comparar contra qué filtrar mal.
import random
@pytest.fixture
def many_tenants(db, n_tenants: int = 20, operarios_por_tenant: int = 12):
"""
Genera N tenants reales, cada uno con su admin y M operarios, y una
cantidad realista de recursos por tenant (no 1 de cada — suficientes
para que un filtro incorrecto tenga datos de sobra con los que fallar).
Por qué 20 tenants y no 2: un bug como "WHERE tenant_id != otro_tenant_id"
(en vez de "== mi_tenant_id") puede colar datos de UN tenant ajeno sin
que se note con solo 2 — con 19 tenants ajenos en la mesa, la fuga se
vuelve imposible de no ver en las aserciones.
"""
tenants = []
for i in range(n_tenants):
tenant = TenantFactory.create(name=f"Tenant {i}")
admin = UserFactory.create(tenant_id=tenant.id, role="admin")
operarios = [
UserFactory.create(tenant_id=tenant.id, role="operario")
for _ in range(operarios_por_tenant)
]
# Volumen realista, no un job de juguete por tenant
jobs = [JobFactory.create(tenant_id=tenant.id, operario_id=random.choice(operarios).id)
for _ in range(random.randint(15, 40))]
tenants.append({"tenant": tenant, "admin": admin, "operarios": operarios, "jobs": jobs})
return tenants
Qué probar con este fixture (tres cosas, no solo "¿aísla?")
a) Aislamiento a escala — toma un tenant al azar del medio de la lista (no el primero ni el último — esas posiciones ocultan bugs de índice/límite), y verifica que su listado no contiene NI UN SOLO id de los otros N-1:
def test_listado_no_filtra_nada_de_los_otros_19_tenants(many_tenants):
objetivo = many_tenants[10] # ni el primero ni el último
ids_ajenos = {j.id for t in many_tenants if t is not objetivo for j in t["jobs"]}
response = client.get("/trabajos", headers=_auth(objetivo["admin"]))
ids_recibidos = {j["id"] for j in response.json()}
assert ids_recibidos.isdisjoint(ids_ajenos)
assert ids_recibidos == {j.id for j in objetivo["jobs"]}
b) El coste no escala con el tamaño total del sistema, solo con el del tenant — el bug clásico de multi-tenant: un endpoint que hace SELECT * FROM jobs y filtra en Python escala con el total de TODOS los tenants, no con el tuyo. Con 20 tenants de 40 jobs cada uno (800 filas totales) contra 2 tenants de 40 (80 filas), la query del endpoint de UN tenant debería tardar y costar lo mismo:
def test_query_count_no_crece_con_el_numero_de_tenants(many_tenants, query_counter):
objetivo = many_tenants[10]
with query_counter() as counter:
client.get("/trabajos", headers=_auth(objetivo["admin"]))
# Con eager loading correcto, el número de queries es constante
# (1-2), no proporcional a operarios_por_tenant ni a n_tenants.
assert counter.count <= 2
c) Los ids "vecinos" son el caso de prueba, no un extra — si tu ORM usa autoincrement, los ids de un tenant y el siguiente son consecutivos. Prueba explícitamente contra el tenant creado justo antes y justo después del objetivo (los ids más parecidos en valor, el caso donde un <=/>= mal puesto en vez de == se nota menos):
def test_no_hay_fuga_con_el_tenant_vecino_en_ids(many_tenants):
objetivo = many_tenants[10]
vecino = many_tenants[11] # tenant creado justo después → ids consecutivos
job_vecino = vecino["jobs"][0]
response = client.get(f"/trabajos/{job_vecino.id}", headers=_auth(objetivo["admin"]))
assert response.status_code == 404
3. Disciplina de verificación (heredada de testing-multitenant.md)
Igual que con los tests de IDOR: un test que nunca se confirmó que falla contra el código con el bug no prueba nada.
git stash
pytest tests/test_crud_completeness_scale.py -v # debe FALLAR contra el código viejo
git stash pop
pytest tests/test_crud_completeness_scale.py -v # debe PASAR con el fix
Si un test de esta skill pasa en ambos casos, está mal escrito — no lo des por bueno.
4. Cuándo NO hace falta esto
No escales el fixture a N tenants para un recurso que ya está cubierto por el patrón de 2 tenants y que no tiene ninguna query "sospechosa" (sin joinedload, con agregaciones, con LIKE, con ordenación por campo de otra tabla). Ejecutar 20 tenants por cada recurso trivial es ruido, no rigor — resérvalo para: listados con filtros complejos, informes/agregados (el caso típico de get_informe_mensual, get_calendario), y cualquier endpoint que en backend-reviewer ya se marcó con una query sospechosa en Lente 2.