# Saas Crud Completeness

> Verifica que el CRUD de cada recurso de un SaaS multi-tenant esté completo (crear/leer/editar/borrar según corresponda, cada uno tenant-aislado, paginado, validado y con su excepción bien propagada a HTTP) y que ese comportamiento se sostenga a escala real — muchos tenants, muchos usuarios por tenant. Úsalo después de auditar el aislamiento (ver saas-multitenant-architecture) o antes de dar una feature por terminada.

- Skill: `fernando-delrio/saas-crud-completeness` (Agent Skill)
- Install (CLI): `npx skillmds@latest add fernando-delrio/saas-crud-completeness`
- Raw SKILL.md: https://api.skillmd.com/api/skills/fernando-delrio/saas-crud-completeness/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: fernando-delrio (https://skillmd.com/u/fernando-delrio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/fernando-delrio/saas-crud-completeness

---


# 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:
1. **¿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)?
2. **¿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.

```python
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:

```python
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:

```python
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):

```python
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.

```bash
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.

