Anexo de la Práctica 11 · 120 minutos

El servidor con pruebas: las tres fallas, demostradas

Las tres fallas de la clase de calidad, resueltas paso a paso con pytest: un profesor que borraba lo ajeno, un reintento que duplicaba y fotos que nadie usaba. Cada corrección llega con la prueba que la demuestra, y todo termina en la matriz de trazabilidad que pide la entrega final del reto.

120 min en clase Nivel IA: tutor pytest dentro de Docker, contra PostgreSQL y RustFS de verdad Las tarjetas 1, 2 y 3 de la clase de calidad Pruebas negativas de seguridad Matriz de trazabilidad para el reto

Prerrequisitos. Docker Desktop funcionando (el anexo de imágenes con Docker) y la Práctica 11: la matriz del final cita sus pruebas. La clase de calidad del 6 de octubre ayuda, pero no hace falta haberla terminado: aquí se empieza de cero.

El código de arranque. Es la rama calidad del servidor de Avisos, tal como llegó a clase: el servidor de la Práctica 10 con sus pruebas. Si tienes corriendo otro servidor (el de la Práctica 10 o el de tu reto), apágalo primero con docker compose down en su carpeta: usan los mismos puertos.

git clone https://github.com/rpversontec/practica-11-servidor-con-pruebas.git
cd practica-11-servidor-con-pruebas
cp .env.example .env

Abre .env y cambia JWT_SECRET, CODIGO_PROFESOR y S3_SECRET_KEY por lo que quieras. Las pruebas leen el CODIGO_PROFESOR de ahí.

Cómo trabajar

Las pruebas corren dentro del contenedor, contra la base y el almacén de verdad: docker compose exec api uv run --no-sync pytest. Añade -v para ver cada prueba por nombre, o el archivo para correr solo ese: … pytest tests/test_permisos.py.

No hace falta reconstruir. app/ y tests/ están montadas en el contenedor: guardas un archivo y la siguiente corrida ya lo usa.

Un commit en cada checkpoint: git add -A ; git commit -m "checkpoint a3". Si algo se rompe sin arreglo, git restore . te regresa al último bueno.

Las tres fallas

La falla A quién perjudica Bloque
Bruno, profesor, borra el aviso de Ana A Ana, y a quien confiaba en ese aviso A
Se va la señal, el usuario reintenta, y el aviso sale dos veces A todos los que leen el tablón B
La imagen sube, el aviso falla, y la imagen se queda para siempre Al que paga el almacén, cada mes un poco más C

Y el Bloque D convierte todo en evidencia para la entrega final del reto: la matriz de trazabilidad.

Cuando termines, esto tiene que ser cierto

  1. pytest dice 19 passed, sin ninguna saltada.
  2. Bruno no puede borrar el aviso de Ana, y Ana sí puede borrar el suyo.
  3. Repetir una publicación con la misma Idempotency-Key devuelve el aviso que ya existía.
  4. python -m app.limpieza borra las imágenes huérfanas viejas, y nunca una que use un aviso.
  5. docs/matriz-trazabilidad.md dice qué requisito prueba cada prueba, y su estado es honesto.

Parte 0 · 15 minAntes del código

P0.1 — Levantar y correr8 min

docker compose up -d --build
docker compose exec api uv run --no-sync pytest

La primera vez tarda un par de minutos: descarga PostgreSQL y RustFS y construye la imagen. Lo que tienes que ver al final:

tests/test_contrato.py .....                                             [ 45%]
tests/test_duplicados.py .s                                              [ 63%]
tests/test_huerfanas.py .                                                [ 72%]
tests/test_permisos.py ..F                                               [100%]

=================================== FAILURES ===================================
__________________ test_bruno_no_puede_borrar_el_aviso_de_ana __________________
…
>       assert r.status_code == 403, f"Bruno pudo borrar el aviso de Ana: el servidor respondió {r.status_code}"
E       AssertionError: Bruno pudo borrar el aviso de Ana: el servidor respondió 204
E       assert 204 == 403
…
==================== 1 failed, 9 passed, 1 skipped in 4.64s ====================

Un punto por prueba que pasa, una F por la que falla y una s por la que se saltó. La que falla no es un error tuyo: es el defecto del Bloque A. pytest dice qué línea, qué esperaba (403) y qué obtuvo (204).

P0.2 — Cómo está hecha una prueba7 min

Abre tests/test_permisos.py:

tests/test_permisos.py
def test_bruno_no_puede_borrar_el_aviso_de_ana(cliente: TestClient, ana: Persona, bruno: Persona, publicar):
    aviso = publicar(ana)

    r = cliente.delete(f"/api/avisos/{aviso['id']}", headers=bruno.headers)

    assert r.status_code == 403, f"Bruno pudo borrar el aviso de Ana: el servidor respondió {r.status_code}"
    # Y el aviso sigue en el tablón.
    tablon = cliente.get("/api/avisos", headers=ana.headers).json()
    assert aviso["id"] in [a["id"] for a in tablon]

Tres partes, como en la Práctica 11: preparar (Ana publica), actuar (Bruno borra), comprobar (assert).

Los parámetros son fixtures. pytest ve que la función pide ana y busca en tests/conftest.py una función con ese nombre: registra una profesora nueva y la entrega con su token. Lo mismo bruno (profesor), carla (alumna), publicar y subir. Y cliente: la API en el mismo proceso que la prueba, sin red de por medio, hablándole a la base y al almacén de verdad.

Ejercicio 0 Dos decisiones de conftest.py en parejas · 3 min
  1. Cada prueba registra a sus personas con un sufijo al azar (ana.f07ea4b1). ¿Qué pasaría si todas usaran ana?
  2. Las pruebas corren contra el PostgreSQL de verdad, no contra una base falsa. ¿Qué se gana? ¿Qué se pierde?
Respuesta
  1. La segunda prueba fallaría al registrarla (409, ya existe), y la segunda corrida de pytest también. Con el sufijo, cada prueba tiene sus personas y se pueden correr las veces que sea, en cualquier orden.
  2. Se gana que lo que pasa es lo que de verdad hace el servidor: una prueba de permisos contra una base de mentira no demuestra que la real los respete. Se pierde velocidad, y se necesita Docker encendido. Las de la app (Práctica 11) eligieron lo contrario, un servidor de mentira, porque ahí lo que se prueba es la app.

Bloque A · 25 minEl aviso ajeno

A1 — Tengo sesión, tengo rol… ¿pero es mío?5 min

El token de Bruno es válido, y su rol es profesor. Las dos preguntas que el servidor ya hace tienen respuesta «sí»:

Pregunta Se llama Dónde se responde
¿Quién eres? Autenticación usuario_actual, en deps.py: el token
¿Tu rol puede borrar avisos? Autorización por rol requiere_profesor, en deps.py
¿Este aviso es tuyo? Autorización sobre el recurso Nadie

La tercera solo se puede responder dentro de borrar: es la única que conoce el aviso. Y va en el servidor, no en la app. Si la app escondiera el botón en los avisos ajenos, la prueba seguiría roja: le pega directo a la API, como lo haría cualquiera con un token y curl.

A2 — La corrección8 min

En app/routers/avisos.py, la función borrar (es la última del archivo) queda así:

app/routers/avisos.py
@router.delete("/{id}", status_code=204)
def borrar(id: int, quien: Identidad = Depends(requiere_profesor), db: Session = Depends(get_db)) -> Response:
    aviso = db.get(Aviso, id)
    if aviso is None:
        raise ApiError(404, f"No existe el aviso {id}")
    # Tener el rol no basta: el aviso tiene que ser suyo. El autor lo guardó el servidor
    # al publicar, con la identidad del token; aquí se compara con la del token de ahora.
    if aviso.autor != quien.usuario:
        raise ApiError(403, "Solo quien publicó el aviso puede borrarlo", "no_es_tuyo", f'El aviso {id} es de "{aviso.autor}".')
    db.delete(aviso)
    db.commit()
    # Primero la fila y después el objeto: si el almacén falla, queda un archivo huérfano
    # (estorba) en vez de un aviso que apunta a una imagen que ya no existe (truena).
    if aviso.imagen:
        almacen.borrar(aviso.imagen)
    return Response(status_code=204)

Dos cambios: _ pasa a llamarse quien (antes la identidad se recibía y se tiraba), y el if nuevo. Corre todo otra vez: 10 passed, 1 skipped.

Las otras dos de test_permisos.py importan tanto como la roja. test_ana_puede_borrar_su_propio_aviso es el caso válido: una «corrección» que no deja borrar a nadie también pondría verde la de Bruno. Si la de Ana se pone roja, la corrección está mal.

¿403 o 404? La prueba pide 403: «existe, pero no es tuyo». Un 404 («para ti no existe») esconde que el aviso existe. Las dos son defendibles si se documentan. Lo que no se vale es cambiar la prueba para que pase.

A3 — Las pruebas negativas12 min

La entrega final del reto pide evidencia de pruebas negativas: casos en los que el sistema rechaza lo que no debe permitir. La de Bruno es una. Estas son cuatro más:

tests/test_seguridad.py
"""Pruebas negativas: lo que el servidor tiene que RECHAZAR.

Ya pasan desde el primer día. Son la evidencia de seguridad del reto: «demuestren
al menos algunos casos en los que el sistema rechaza una acción no permitida».
"""

import uuid

from fastapi.testclient import TestClient

from app.config import settings
from app.db import SessionLocal
from app.models import Usuario
from app.security import sign_jwt

from .conftest import PNG_CHICO, Persona


def test_un_token_vencido_no_sirve(cliente: TestClient, carla: Persona):
    # Sin esperar cinco minutos: se firma con la llave real del servidor un token que venció
    # hace uno. Firmarlo con OTRA llave probaría la firma, no el vencimiento.
    vencido = sign_jwt(carla.usuario, carla.rol, -60, settings.jwt_secret)

    r = cliente.get("/api/avisos", headers={"Authorization": f"Bearer {vencido}"})

    assert r.status_code == 401
    assert r.json()["code"] == "token_expirado"


def test_mandar_rol_profesor_sin_el_codigo_crea_un_alumno(cliente: TestClient):
    # El cliente puede mandar lo que quiera. El rol lo decide el servidor, con el código.
    cuerpo = {"usuario": f"tramposa.{uuid.uuid4().hex[:8]}", "password": "secreta123", "rol": "profesor"}

    r = cliente.post("/api/auth/register", json=cuerpo)

    assert r.status_code == 201
    assert r.json()["rol"] == "alumno"


def test_una_alumna_no_puede_subir_imagenes(cliente: TestClient, carla: Persona):
    r = cliente.post("/api/imagenes", headers=carla.headers, files={"archivo": ("foto.png", PNG_CHICO, "image/png")})

    assert r.status_code == 403


def test_la_contrasena_no_se_guarda_en_claro(carla: Persona):
    # Se lee la fila directo de PostgreSQL, como la vería quien se robe un respaldo.
    with SessionLocal() as db:
        fila = db.get(Usuario, carla.usuario)

    assert fila is not None
    assert "secreta123" not in fila.password_hash
    assert fila.password_hash.startswith("$argon2id$")

Córrelo solo: docker compose exec api uv run --no-sync pytest tests/test_seguridad.py -v. Las cuatro pasan desde el primer día. No arreglan nada: demuestran que cuatro controles existen, y avisarán el día que alguien los rompa sin querer.

El token vencido, sin esperar. sign_jwt es la misma función con la que el servidor firma sus tokens. Con una vida de -60 segundos, el token nace vencido. Firmarlo con otra llave probaría la firma, no la fecha.

La contraseña, desde la base. La prueba no le pregunta a la API: lee la fila de PostgreSQL directo, como la vería quien se robe un respaldo.

Ahora véla en rojo. Cambia el -60 por 60 y corre otra vez: el token sigue vivo, el servidor responde 200, y pytest dice assert 200 == 401. Regrésalo.


Bloque B · 35 minEl doble envío

B1 — El contrato8 min

El teléfono manda el aviso, el servidor lo guarda, y la respuesta se pierde porque se fue la señal. La app dice «No hay conexión», y el usuario toca Publicar otra vez. El servidor recibe dos peticiones idénticas y guarda dos avisos.

Desactivar el botón mientras envía (la Práctica 11 lo prueba) protege del doble toque, no de este reintento: cuando el usuario vuelve a tocar, el primer envío ya terminó. El único que puede reconocer «esto ya lo guardé» es el servidor. Para eso hace falta un identificador de la operación, no del contenido: dos avisos con el mismo título son válidos.

El contrato, con el encabezado Idempotency-Key (una propuesta de estándar de la IETF que ya usan Stripe y otras API):

Si llega… El servidor…
Sin Idempotency-Key Crea un aviso nuevo, como hoy
Una clave nueva Crea el aviso y recuerda la clave
Una clave que ya vio, con el mismo contenido Devuelve el aviso que ya guardó. No crea otro
Una clave que ya vio, con otro contenido 422 con code: clave_reutilizada
La misma clave, pero de otra persona Es otra clave: crea el aviso de esa persona

La clave la inventa el teléfono: un UUID al abrir la pantalla de publicar, el mismo en cada reintento.

B2 — Dónde se recuerda la clave7 min

En app/models.py, agrega UniqueConstraint al import de SQLAlchemy:

app/models.py
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text, UniqueConstraint, func

Y al final del archivo, una tabla nueva:

app/models.py
class Idempotencia(Base):
    """Una fila por publicación que llegó con `Idempotency-Key`: quién, con qué clave, qué aviso salió.

    La restricción única es la que de verdad impide el duplicado. La consulta de
    `crear` ataja el reintento normal; si dos envíos llegan al mismo tiempo, los dos
    pasan la consulta, y PostgreSQL deja guardar solo a uno.
    """

    __tablename__ = "idempotencia"
    __table_args__ = (UniqueConstraint("usuario", "clave"),)

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    usuario: Mapped[str] = mapped_column(String(32))
    clave: Mapped[str] = mapped_column(String(64))
    # sha256 del aviso que llegó con esa clave: la misma clave con otro contenido no es un reintento.
    huella: Mapped[str] = mapped_column(String(64))
    # Si se borra el aviso, su fila se va con él (ON DELETE CASCADE, lo hace PostgreSQL).
    aviso_id: Mapped[int] = mapped_column(ForeignKey("avisos.id", ondelete="CASCADE"))
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

La restricción es única por (usuario, clave), no por clave sola: así la clave de Ana no le estorba a Bruno, y Bruno no puede recibir el aviso de Ana adivinando su clave.

No hay migración. Al arrancar, el servidor crea las tablas que faltan (create_all en main.py), y pytest también lo arranca. Una tabla nueva aparece sola; cambiar una que ya existe sí pediría una migración.

B3 — Publicar con clave10 min

En app/routers/avisos.py, los imports de arriba (todo lo que va antes de router = ) quedan así:

app/routers/avisos.py
from fastapi import APIRouter, Depends, Header, Request, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from .. import almacen
from ..db import SessionLocal, get_db
from ..deps import Identidad, requiere_profesor, usuario_actual
from ..errors import ApiError
from ..models import Aviso, Idempotencia
from ..schemas import AvisoOut, NuevoAviso
from ..security import sha256

Y la función crear completa (desde @router.post hasta antes de @router.delete) se cambia por estas dos:

app/routers/avisos.py
def ya_publicado(db: Session, quien: Identidad, clave: str, huella: str) -> Aviso | None:
    """El aviso que ya salió con esta clave, o None si es la primera vez que llega."""
    previo = db.scalar(select(Idempotencia).where(Idempotencia.usuario == quien.usuario, Idempotencia.clave == clave))
    if previo is None:
        return None
    if previo.huella != huella:
        raise ApiError(
            422,
            "Esa clave ya se usó para publicar otro aviso",
            "clave_reutilizada",
            "Genera una Idempotency-Key nueva por publicación; repítela solo al reintentar la misma.",
        )
    return db.get(Aviso, previo.aviso_id)


@router.post("", response_model=AvisoOut, status_code=201)
def crear(
    body: NuevoAviso,
    quien: Identidad = Depends(requiere_profesor),
    db: Session = Depends(get_db),
    # La inventa el teléfono: una por publicación, la misma en cada reintento. Es opcional:
    # sin ella, cada envío es un aviso nuevo, como antes.
    clave: str | None = Header(default=None, alias="Idempotency-Key", max_length=64),
) -> Aviso:
    huella = sha256(body.model_dump_json())
    if clave is not None and (previo := ya_publicado(db, quien, clave, huella)) is not None:
        return previo
    # Una clave con buena forma no basta: la imagen tiene que estar en el almacén.
    if body.imagen is not None and not almacen.existe(body.imagen):
        raise ApiError(422, "Esa imagen no existe", "imagen_inexistente", "Súbela primero con POST /api/imagenes y manda la clave que te devuelve.")
    aviso = Aviso(titulo=body.titulo, cuerpo=body.cuerpo, autor=quien.usuario, imagen=body.imagen)
    db.add(aviso)
    if clave is not None:
        db.flush()  # PostgreSQL asigna aviso.id sin cerrar la transacción
        db.add(Idempotencia(usuario=quien.usuario, clave=clave, huella=huella, aviso_id=aviso.id))
    try:
        db.commit()  # el aviso y su clave se guardan juntos, o ninguno
    except IntegrityError:
        # Otro envío con la misma clave se guardó primero: los dos pasaron la consulta
        # de arriba, y la restricción única dejó pasar solo a uno. Se responde el suyo.
        db.rollback()
        previo = ya_publicado(db, quien, clave, huella) if clave else None
        if previo is None:
            raise
        return previo
    db.refresh(aviso)
    return aviso

Header(alias="Idempotency-Key"). FastAPI lee el encabezado y lo entrega como clave. Con max_length=64, una clave más larga es un 422 antes de entrar a la función.

La huella. sha256 del aviso ya validado (sin espacios en las orillas). Mismo contenido, misma huella: es un reintento. Otra huella con la misma clave: es un error del cliente.

El aviso y su clave en un solo commit. flush le pide a PostgreSQL el id del aviso sin cerrar la transacción. Si algo falla antes del commit, no queda ni el aviso ni la clave: nunca un aviso sin su clave, que el siguiente reintento duplicaría.

El except. Dos envíos que llegan al mismo tiempo pasan los dos por ya_publicado antes de que cualquiera guarde. El segundo commit choca con la restricción única: PostgreSQL rechaza la fila, y ese envío responde con el aviso del primero.

B4 — Las pruebas del contrato10 min

tests/test_duplicados.py completo:

tests/test_duplicados.py
"""Tarjeta 2 · Envié una vez, aparecieron dos.

El contrato: el teléfono manda `Idempotency-Key` con un UUID que inventa al abrir
la pantalla de publicar. El mismo envío con la misma clave devuelve el aviso que ya
se guardó; la misma clave con otro contenido es un error. Sin clave, cada envío es
un aviso nuevo: publicar dos avisos con el mismo título es válido.
"""

import uuid

from fastapi.testclient import TestClient

from .conftest import Persona

CUERPO = {"titulo": "Feria de proyectos", "cuerpo": "El viernes a las 12:00 en el patio central."}


def avisos_de(cliente: TestClient, quien: Persona) -> list[dict]:
    return [a for a in cliente.get("/api/avisos", headers=quien.headers).json() if a["autor"] == quien.usuario]


def test_sin_clave_dos_envios_son_dos_avisos(ana: Persona, publicar):
    primero = publicar(ana, titulo="Feria de proyectos")
    segundo = publicar(ana, titulo="Feria de proyectos")

    assert primero["id"] != segundo["id"]


def test_reintentar_con_la_misma_clave_no_crea_otro_aviso(cliente: TestClient, ana: Persona):
    headers = {**ana.headers, "Idempotency-Key": str(uuid.uuid4())}

    primero = cliente.post("/api/avisos", headers=headers, json=CUERPO)
    reintento = cliente.post("/api/avisos", headers=headers, json=CUERPO)

    assert primero.status_code == 201
    assert reintento.status_code == 201
    assert reintento.json()["id"] == primero.json()["id"]
    assert len(avisos_de(cliente, ana)) == 1


def test_la_misma_clave_con_otro_contenido_se_rechaza(cliente: TestClient, ana: Persona):
    headers = {**ana.headers, "Idempotency-Key": str(uuid.uuid4())}
    cliente.post("/api/avisos", headers=headers, json=CUERPO)

    r = cliente.post("/api/avisos", headers=headers, json={**CUERPO, "titulo": "Otro aviso distinto"})

    assert r.status_code == 422
    assert r.json()["code"] == "clave_reutilizada"
    assert len(avisos_de(cliente, ana)) == 1


def test_la_clave_de_ana_no_le_estorba_a_bruno(cliente: TestClient, ana: Persona, bruno: Persona):
    # La clave es única por persona, no en todo el servidor: Bruno no puede «adivinar»
    # la clave de Ana y recibir su aviso, ni bloquearle una publicación.
    clave = str(uuid.uuid4())

    de_ana = cliente.post("/api/avisos", headers={**ana.headers, "Idempotency-Key": clave}, json=CUERPO)
    de_bruno = cliente.post("/api/avisos", headers={**bruno.headers, "Idempotency-Key": clave}, json=CUERPO)

    assert de_bruno.status_code == 201
    assert de_bruno.json()["id"] != de_ana.json()["id"]
    assert de_bruno.json()["autor"] == bruno.usuario

Corre todo: 17 passed. Ya no hay ninguna saltada.

¿Qué pasó con test_hoy_el_mismo_envio_dos_veces_crea_dos_avisos? Era una prueba de caracterización: describía cómo era el servidor, no cómo debía ser. Con el contrato nuevo, lo que dice sigue siendo cierto sin clave, y es deseable: dos avisos con el mismo título son válidos. Por eso no se borra; cambia de nombre para decir lo que ahora es: una regla del contrato.

avisos_de filtra el tablón por autor: comprueba que el reintento no dejó un segundo aviso escondido, aunque haya respondido el id correcto.

Ejercicio B4 La carrera 4 min · por escrito

Las cuatro pruebas mandan los envíos uno después del otro. ¿Qué parte de crear no ejecuta ninguna? ¿Cómo escribirías una prueba que sí la ejecute?

Respuesta

El except IntegrityError: solo corre si dos envíos con la misma clave pasan ya_publicado al mismo tiempo. Una prueba secuencial nunca lo logra. Para provocarlo hacen falta dos hilos que manden la petición a la vez (threading y una barrera que los suelte juntos), repetido varias veces porque no siempre chocan. Se comprobó a mano para esta guía: con cuatro envíos simultáneos, veinte veces, PostgreSQL rechazó 57 filas repetidas y quedaron exactamente veinte avisos. Que se haya comprobado a mano, y no con una prueba que se repita sola, es justo lo que la matriz del Bloque D tiene que decir.


Bloque C · 25 minLa imagen huérfana

C1 — La política5 min

Publicar con imagen son dos peticiones: primero la imagen va al almacén (RustFS) y después el aviso a la base (PostgreSQL). No hay una transacción que abarque a los dos: si el aviso falla, la imagen ya subió. Eso no se puede impedir. La decisión es qué hacer después.

La política de este anexo:

  • Quién: una función, limpiar_huerfanas, que se corre a mano o cada noche.
  • Cómo sabe que nadie la usa: lista el almacén y borra lo que no aparece en ningún aviso.
  • Cuándo: solo lo que tiene más de una hora. Quien está publicando en este momento ya subió su imagen y su aviso todavía no llega; borrársela sería el peor error posible.

test_si_el_aviso_falla_la_imagen_se_queda_en_el_almacen sigue siendo cierta después de este bloque: la imagen se queda… hasta que pase la limpieza.

C2 — Listar y limpiar10 min

El almacén aprende a listar lo que tiene. app/almacen.py completo (es el de antes, con dos imports y listar al final):

app/almacen.py
"""El almacén de objetos, en un solo archivo. Todo lo demás le habla a estas seis
funciones y no sabe que del otro lado hay S3.

Se usa boto3, el cliente oficial de Amazon S3: habla con cualquier almacén
compatible —RustFS aquí, MinIO, Cloudflare R2, Amazon S3 en producción—
cambiando solo `S3_ENDPOINT` y las llaves.

Un objeto es una clave ("3f2a….jpg"), unos bytes y su tipo. No hay carpetas, no
hay consultas, no hay transacciones: guardar, leer, preguntar si existe, listar y borrar.
Por eso las imágenes van aquí y no en PostgreSQL, y por eso la base solo guarda
la clave.
"""

from collections.abc import Iterator
from datetime import datetime
from typing import Any

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

from .config import settings

_s3 = boto3.client(
    "s3",
    endpoint_url=settings.s3_endpoint,
    aws_access_key_id=settings.s3_access_key,
    aws_secret_access_key=settings.s3_secret_key,
    # S3 exige una región aunque el almacén sea tuyo; RustFS acepta esta.
    region_name="us-east-1",
    # "path": http://almacen:9000/avisos/clave. El estilo por omisión pone el bucket en el
    # nombre del host (avisos.almacen), y ese nombre no existe en la red de Docker.
    config=Config(s3={"addressing_style": "path"}, connect_timeout=3, retries={"max_attempts": 2}),
)


def preparar() -> None:
    """Crea el bucket si no existe. Se llama una vez, al arrancar."""
    try:
        _s3.head_bucket(Bucket=settings.s3_bucket)
    except ClientError:
        _s3.create_bucket(Bucket=settings.s3_bucket)


def guardar(clave: str, datos: bytes, tipo: str) -> None:
    _s3.put_object(Bucket=settings.s3_bucket, Key=clave, Body=datos, ContentType=tipo)


def abrir(clave: str) -> dict[str, Any] | None:
    """El objeto listo para mandarse por partes, o None si no existe.

    `Body` es un flujo: los bytes salen del almacén conforme el cliente los lee,
    sin cargar la imagen completa en la memoria del servidor.
    """
    try:
        return _s3.get_object(Bucket=settings.s3_bucket, Key=clave)
    except ClientError as e:
        if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
            return None
        raise


def existe(clave: str) -> bool:
    try:
        _s3.head_object(Bucket=settings.s3_bucket, Key=clave)
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
            return False
        raise


def borrar(clave: str) -> None:
    # Borrar lo que no existe no es error en S3: así borrar dos veces no truena.
    _s3.delete_object(Bucket=settings.s3_bucket, Key=clave)


def listar() -> Iterator[tuple[str, datetime]]:
    """Cada objeto del bucket: su clave y cuándo se guardó. S3 los entrega de mil en mil."""
    for pagina in _s3.get_paginator("list_objects_v2").paginate(Bucket=settings.s3_bucket):
        for objeto in pagina.get("Contents", []):
            yield objeto["Key"], objeto["LastModified"]

Y un archivo nuevo, app/limpieza.py:

app/limpieza.py
"""La limpieza de imágenes huérfanas: las que están en el almacén y ningún aviso usa.

Publicar con imagen son dos peticiones, y no hay una transacción que abarque a
PostgreSQL y a RustFS: si el aviso falla, la imagen ya subió. Eso no se puede
impedir; se limpia después. Se corre a mano, o cada noche desde un cron:

    docker compose exec api uv run --no-sync python -m app.limpieza
"""

from datetime import datetime, timedelta, timezone

from sqlalchemy import select

from . import almacen
from .db import SessionLocal
from .models import Aviso

# El margen protege a quien está publicando en este momento: su imagen ya subió y
# su aviso todavía no llega. Solo se borra lo que lleva más de una hora sin aviso.
MARGEN = timedelta(hours=1)


def limpiar_huerfanas(margen: timedelta = MARGEN, ahora: datetime | None = None) -> list[str]:
    """Borra las imágenes que ningún aviso usa y que son más viejas que `margen`. Devuelve sus claves.

    `ahora` es un parámetro para que una prueba decida qué hora es, como
    `tiempoRelativo` en la app de la Práctica 11.
    """
    ahora = ahora or datetime.now(timezone.utc)
    with SessionLocal() as db:
        usadas = set(db.scalars(select(Aviso.imagen).where(Aviso.imagen.is_not(None))))
    borradas = []
    for clave, guardada in almacen.listar():
        if clave not in usadas and ahora - guardada > margen:
            almacen.borrar(clave)
            borradas.append(clave)
    return borradas


if __name__ == "__main__":
    borradas = limpiar_huerfanas()
    print(f"{len(borradas)} imágenes huérfanas borradas")

ahora es un parámetro. Igual que tiempoRelativo en la app: la prueba decide qué hora es. Sin eso, probar «más de una hora» obligaría a esperar una hora.

if __name__ == "__main__". Lo de abajo solo corre cuando el archivo se ejecuta como programa (python -m app.limpieza), no cuando una prueba lo importa.

C3 — Las pruebas de la limpieza10 min

tests/test_huerfanas.py completo:

tests/test_huerfanas.py
"""Tarjeta 3 · La imagen que nadie usa.

Publicar con imagen son dos peticiones: primero la imagen, después el aviso. Si la
segunda falla, la primera ya pasó, y eso no cambia. Lo que cambia es que ahora
alguien limpia: `app/limpieza.py`.
"""

from datetime import datetime, timedelta, timezone

from fastapi.testclient import TestClient

from app import almacen
from app.limpieza import limpiar_huerfanas

from .conftest import Persona


def test_si_el_aviso_falla_la_imagen_se_queda_en_el_almacen(cliente: TestClient, ana: Persona, subir):
    clave = subir(ana)

    # El aviso no pasa la validación del servidor (título de una letra).
    r = cliente.post("/api/avisos", headers=ana.headers, json={"titulo": "X", "cuerpo": "Un cuerpo válido para el aviso.", "imagen": clave})
    assert r.status_code == 422

    try:
        assert almacen.existe(clave), "La imagen ya no está en el almacén"
    finally:
        almacen.borrar(clave)  # la prueba limpia lo que dejó


def test_la_limpieza_borra_la_huerfana_y_respeta_la_que_usa_un_aviso(ana: Persona, subir, publicar):
    usada = subir(ana)
    publicar(ana, imagen=usada)
    huerfana = subir(ana)

    # Dentro de dos horas: la huérfana ya pasó el margen de una hora.
    limpiar_huerfanas(ahora=datetime.now(timezone.utc) + timedelta(hours=2))

    assert almacen.existe(usada), "Se borró una imagen que un aviso sí usa"
    assert not almacen.existe(huerfana), "La huérfana sigue en el almacén"


def test_la_limpieza_no_toca_una_imagen_recien_subida(ana: Persona, subir):
    # Quien está publicando ahora mismo: su imagen ya subió y su aviso todavía no llega.
    clave = subir(ana)

    try:
        limpiar_huerfanas()
        assert almacen.existe(clave), "Se borró la imagen de alguien que estaba publicando"
    finally:
        almacen.borrar(clave)

Las dos caras. La primera prueba comprueba que borra lo que debe y que respeta lo que no. La segunda, el margen. Una limpieza que borra todo pasaría la mitad de la primera, y la tarjeta de la clase lo decía: «una imagen que sí usa un aviso nunca se borra por error».

Corre todo: 19 passed. Y córrela como la correría un administrador:

docker compose exec api uv run --no-sync python -m app.limpieza
0 imágenes huérfanas borradas

Cero, porque las pruebas ya limpiaron. En un servidor de verdad, esto lo corre un cron cada noche: hoy queda como comando a mano, y la matriz lo va a decir.


Bloque D · 20 minLa matriz de trazabilidad

La entrega final del reto pide, por cada requisito: identificador, descripción, criterio de aceptación, prueba o evidencia, resultado y estado. Y que el estado sea honesto: un requisito no se marca como cumplido si no se puede demostrar.

D1 — La evidencia5 min

La salida de pytest, guardada en un archivo que va al repositorio. En PowerShell (la terminal de Android Studio en Windows):

mkdir docs ; mkdir docs/evidencia
docker compose exec -T api uv run --no-sync pytest -v | Out-File -Encoding utf8 docs/evidencia/pytest-servidor.txt

En Git Bash, macOS o Linux, la segunda línea es:

docker compose exec -T api uv run --no-sync pytest -v > docs/evidencia/pytest-servidor.txt

-v escribe una línea por prueba con su nombre completo, y -T evita que Docker abra una terminal (sin ella, pytest podría mandar al archivo sus códigos de color). Ábrelo:

tests/test_permisos.py::test_ana_puede_borrar_su_propio_aviso PASSED     [ 68%]
tests/test_permisos.py::test_una_alumna_no_puede_borrar_avisos PASSED    [ 73%]
tests/test_permisos.py::test_bruno_no_puede_borrar_el_aviso_de_ana PASSED [ 78%]
…
============================== 19 passed in 2.94s ==============================

Cada nombre es único: es lo que se escribe en la matriz, y quien la revise lo encuentra en este archivo con Ctrl+F.

Las pruebas de la app también dejan evidencia. Después de correrlas en Android Studio, Gradle guarda un reporte en app/build/reports/tests/testDebugUnitTest/index.html (las locales) y en app/build/reports/androidTests/connected/debug/index.html (las instrumentadas). Ábrelos en el navegador: una captura de cada uno va al PDF de la entrega.

D2 — La matriz15 min

Esta es la matriz del servidor de Avisos y de la app de la Práctica 11:

Matriz de trazabilidad · Avisos (servidor de este anexo y app de la Práctica 11)

Versión: tag entrega-pruebas. Servidor: 19 pruebas en docs/evidencia/pytest-servidor.txt. App: 26 pruebas en los reportes de Gradle de la Práctica 11.

ID Requisito Criterio de aceptación Resultado Estado Prueba o evidencia
HU-01 Un profesor publica un aviso Dado un profesor con sesión, cuando publica un título y un cuerpo válidos, entonces el aviso aparece en el tablón con él como autor Pasa Cumplido test_contrato.py::test_lo_que_publica_una_profesora_aparece_en_el_tablon
HU-02 Un profesor borra sus avisos, y solo los suyos Ana borra el suyo (204). Bruno, también profesor, no borra el de Ana (403) y el aviso sigue en el tablón. Carla, alumna, no borra ninguno (403) Pasan 3 de 3 Cumplido test_permisos.py::test_ana_puede_borrar_su_propio_aviso, ::test_bruno_no_puede_borrar_el_aviso_de_ana, ::test_una_alumna_no_puede_borrar_avisos
SEG-01 Toda la API exige una sesión válida Sin token, con la firma alterada o vencido: 401 con su code Pasan 3 de 3 Cumplido test_contrato.py::test_sin_token_responde_401_con_la_forma_del_contrato, ::test_un_token_alterado_no_sirve, test_seguridad.py::test_un_token_vencido_no_sirve
SEG-02 El rol lo decide el servidor Mandar "rol": "profesor" sin el código crea un alumno. Una alumna no publica ni sube imágenes (403) Pasan 3 de 3 Cumplido test_seguridad.py::test_mandar_rol_profesor_sin_el_codigo_crea_un_alumno, ::test_una_alumna_no_puede_subir_imagenes, test_contrato.py::test_una_alumna_no_puede_publicar
SEG-03 Las contraseñas no se guardan en claro En la base, el hash empieza con $argon2id$ y no contiene la contraseña Pasa Cumplido test_seguridad.py::test_la_contrasena_no_se_guarda_en_claro
RN-01 El título mide de 3 a 60 caracteres, sin contar los espacios de las orillas App: 3 y 60 valen; 2 y 61 no. Servidor: un título de puros espacios responde 422 y no se guarda Pasan 5 de 5 Cumplido App: AvisoValidatorTest (4 pruebas). Servidor: test_contrato.py::test_un_titulo_de_puros_espacios_no_se_guarda
RNF-01 Tocar Publicar dos veces manda un solo aviso Con el primer envío en el aire, el segundo toque no hace otra petición Pasa Cumplido PublicarViewModelTest.tocar_publicar_dos_veces_manda_un_solo_aviso
RNF-02 Reintentar después de una respuesta perdida no publica dos veces Con la misma Idempotency-Key, el servidor devuelve el aviso que ya guardó. La misma clave con otro contenido: 422. La clave de una persona no afecta a otra Pasan 3 de 3 Parcialmente cumplido: el servidor lo cumple, pero la app todavía no manda la clave (RNF-03) test_duplicados.py::test_reintentar_con_la_misma_clave_no_crea_otro_aviso, ::test_la_misma_clave_con_otro_contenido_se_rechaza, ::test_la_clave_de_ana_no_le_estorba_a_bruno
RNF-03 La app manda una Idempotency-Key por publicación El mismo UUID en cada reintento de la misma publicación, y uno nuevo para la siguiente Sin probar Pendiente Ninguna todavía
RNF-04 Las imágenes sin aviso no se acumulan Una huérfana con más de una hora se borra; la que usa un aviso, no; una recién subida, tampoco Pasan 2 de 2 Parcialmente cumplido: la limpieza existe y está probada, pero nada la corre sola; hoy es un comando a mano test_huerfanas.py::test_la_limpieza_borra_la_huerfana_y_respeta_la_que_usa_un_aviso, ::test_la_limpieza_no_toca_una_imagen_recien_subida
RNF-05 La app reduce la foto antes de subirla Una foto de 3000 × 2000 sale con 1280 px de lado mayor; una chica no se agranda Pasan 2 de 2 Cumplido CompresorDeImagenTest (2 pruebas)
ACC-01 TalkBack anuncia la imagen elegida y el botón para quitarla Los dos se encuentran por su descripción Pasa Parcialmente cumplido: en el tablón todas las imágenes se anuncian igual, «Imagen del aviso» (Ejercicio C3 de la Práctica 11) PublicarScreenTest.la_imagen_elegida_se_anuncia_y_se_puede_quitar
El archivo, para copiarlo a docs/matriz-trazabilidad.md
docs/matriz-trazabilidad.md
**Matriz de trazabilidad · Avisos (servidor de este anexo y app de la Práctica 11)**

Versión: tag `entrega-pruebas`. Servidor: 19 pruebas en `docs/evidencia/pytest-servidor.txt`. App: 26 pruebas en los reportes de Gradle de la Práctica 11.

| ID | Requisito | Criterio de aceptación | Resultado | Estado | Prueba o evidencia |
| --- | --- | --- | --- | --- | --- |
| HU-01 | Un profesor publica un aviso | Dado un profesor con sesión, cuando publica un título y un cuerpo válidos, entonces el aviso aparece en el tablón con él como autor | Pasa | Cumplido | `test_contrato.py::test_lo_que_publica_una_profesora_aparece_en_el_tablon` |
| HU-02 | Un profesor borra sus avisos, y solo los suyos | Ana borra el suyo (204). Bruno, también profesor, no borra el de Ana (403) y el aviso sigue en el tablón. Carla, alumna, no borra ninguno (403) | Pasan 3 de 3 | Cumplido | `test_permisos.py::test_ana_puede_borrar_su_propio_aviso`, `::test_bruno_no_puede_borrar_el_aviso_de_ana`, `::test_una_alumna_no_puede_borrar_avisos` |
| SEG-01 | Toda la API exige una sesión válida | Sin token, con la firma alterada o vencido: 401 con su `code` | Pasan 3 de 3 | Cumplido | `test_contrato.py::test_sin_token_responde_401_con_la_forma_del_contrato`, `::test_un_token_alterado_no_sirve`, `test_seguridad.py::test_un_token_vencido_no_sirve` |
| SEG-02 | El rol lo decide el servidor | Mandar `"rol": "profesor"` sin el código crea un alumno. Una alumna no publica ni sube imágenes (403) | Pasan 3 de 3 | Cumplido | `test_seguridad.py::test_mandar_rol_profesor_sin_el_codigo_crea_un_alumno`, `::test_una_alumna_no_puede_subir_imagenes`, `test_contrato.py::test_una_alumna_no_puede_publicar` |
| SEG-03 | Las contraseñas no se guardan en claro | En la base, el hash empieza con `$argon2id$` y no contiene la contraseña | Pasa | Cumplido | `test_seguridad.py::test_la_contrasena_no_se_guarda_en_claro` |
| RN-01 | El título mide de 3 a 60 caracteres, sin contar los espacios de las orillas | App: 3 y 60 valen; 2 y 61 no. Servidor: un título de puros espacios responde 422 y no se guarda | Pasan 5 de 5 | Cumplido | App: `AvisoValidatorTest` (4 pruebas). Servidor: `test_contrato.py::test_un_titulo_de_puros_espacios_no_se_guarda` |
| RNF-01 | Tocar Publicar dos veces manda un solo aviso | Con el primer envío en el aire, el segundo toque no hace otra petición | Pasa | Cumplido | `PublicarViewModelTest.tocar_publicar_dos_veces_manda_un_solo_aviso` |
| RNF-02 | Reintentar después de una respuesta perdida no publica dos veces | Con la misma `Idempotency-Key`, el servidor devuelve el aviso que ya guardó. La misma clave con otro contenido: 422. La clave de una persona no afecta a otra | Pasan 3 de 3 | Parcialmente cumplido: el servidor lo cumple, pero la app todavía no manda la clave (RNF-03) | `test_duplicados.py::test_reintentar_con_la_misma_clave_no_crea_otro_aviso`, `::test_la_misma_clave_con_otro_contenido_se_rechaza`, `::test_la_clave_de_ana_no_le_estorba_a_bruno` |
| RNF-03 | La app manda una `Idempotency-Key` por publicación | El mismo UUID en cada reintento de la misma publicación, y uno nuevo para la siguiente | Sin probar | Pendiente | Ninguna todavía |
| RNF-04 | Las imágenes sin aviso no se acumulan | Una huérfana con más de una hora se borra; la que usa un aviso, no; una recién subida, tampoco | Pasan 2 de 2 | Parcialmente cumplido: la limpieza existe y está probada, pero nada la corre sola; hoy es un comando a mano | `test_huerfanas.py::test_la_limpieza_borra_la_huerfana_y_respeta_la_que_usa_un_aviso`, `::test_la_limpieza_no_toca_una_imagen_recien_subida` |
| RNF-05 | La app reduce la foto antes de subirla | Una foto de 3000 × 2000 sale con 1280 px de lado mayor; una chica no se agranda | Pasan 2 de 2 | Cumplido | `CompresorDeImagenTest` (2 pruebas) |
| ACC-01 | TalkBack anuncia la imagen elegida y el botón para quitarla | Los dos se encuentran por su descripción | Pasa | Parcialmente cumplido: en el tablón todas las imágenes se anuncian igual, «Imagen del aviso» (Ejercicio C3 de la Práctica 11) | `PublicarScreenTest.la_imagen_elegida_se_anuncia_y_se_puede_quitar` |

Cómo leerla:

  • Un requisito puede tener varias pruebas, en varias capas. RN-01 (el título) tiene cuatro en la app y una en el servidor: la app avisa antes de enviar, el servidor no confía en que lo haya hecho.
  • «Pasa» no es lo mismo que «Cumplido». RNF-02 tiene sus tres pruebas en verde y aun así queda parcialmente cumplido: el servidor está listo, pero la app no manda la clave, y un usuario real todavía puede duplicar.
  • Lo pendiente se escribe. RNF-03 no tiene prueba ni código. Escribirlo no resta: lo que resta es que el profesor lo descubra en la demostración.
  • La prueba manual cuenta, y se dice que es manual. El choque de envíos simultáneos (Ejercicio B4) se comprobó a mano. Si lo pusieran en una fila, la evidencia sería ese script y su salida, y el estado reflejaría que no se repite solo.

Para su reto. Los ID son los de su backlog, no estos. Si su servidor es FastAPI, el patrón de este anexo se copia tal cual: pytest y httpx2 en el grupo dev de pyproject.toml, el Dockerfile sin --no-dev, tests/ montada en el contenedor, y un conftest.py con sus personas. Y si su aviso de privacidad promete que una persona puede borrar lo que publicó, test_ana_puede_borrar_su_propio_aviso es el tipo de prueba que lo demuestra.

Ejercicio D2 Tres filas de su reto 6 min · en equipo

Escojan tres requisitos de su backlog: uno de seguridad, uno funcional y uno que hoy no puedan demostrar. Escriban su fila completa, con el estado honesto.

Qué revisar

Que el criterio se pueda comprobar sin opinar («responde 403 y el aviso sigue», no «es seguro»). Que la prueba tenga un nombre que se encuentre en la evidencia. Y que el que no pueden demostrar diga Pendiente o Parcialmente cumplido, y por qué.


Retos para casa

  1. RNF-03: la app manda la clave. En la app de la Práctica 11, un UUID por publicación guardado en el SavedStateHandle (sobrevive a la rotación) y enviado como @Header("Idempotency-Key") en crearAviso. La prueba, con la FakeAvisosApi: dos intentos de la misma publicación llevan la misma clave.
  2. La carrera, automatizada. La prueba del Ejercicio B4, con hilos.
  3. La limpieza, sola. Que la limpieza corra cada noche sin que nadie se acuerde: un servicio más en docker-compose.yml, o un cron del servidor.
  4. Las claves viejas. La tabla idempotencia crece para siempre. Borren las filas de más de 24 horas, con su prueba (ahora como parámetro, otra vez).

Problemas comunes

Síntoma Causa Solución
env file …\.env not found No copiaste .env.example cp .env.example .env
Bind for 127.0.0.1:9000 failed: port is already allocated (o el 8000) Otro servidor (el de la Práctica 10, el de tu reto) está corriendo docker compose down en la carpeta de ese otro servidor
service "api" is not running El servidor no está levantado docker compose up -d
NameError: name 'quien' is not defined en borrar Agregaste el if pero la firma sigue diciendo _: Identidad Renómbralo a quien, como en el A2
ImportError while loading conftest con NameError: name 'Header' is not defined Falta Header en el import de fastapi Los imports del B3
AttributeError: module 'app.almacen' has no attribute 'listar' Escribiste limpieza.py pero no actualizaste almacen.py El C2
La de Ana se pone roja después de corregir La corrección no deja borrar a nadie Compara aviso.autor con quien.usuario, no el rol
El token '&&' no es un separador de instrucciones válido PowerShell 5.1 Separa con ;

IA en esta práctica — nivel tutor

Permitido: explicar, diagnosticar, contrastar tu diseño. Prohibido: generar el código de los entregables.

  • “¿Qué diferencia hay entre autenticación y autorización? Dame un ejemplo donde la segunda falle aunque la primera funcione.”
  • “¿Por qué una restricción única en la base protege de dos envíos simultáneos y una consulta previa no?”
  • “Revisa mi matriz de trazabilidad: ¿hay algún requisito marcado como cumplido que mi evidencia no demuestre?”

Si usas IA, se declara

Todo commit que contenga código generado o reescrito por una IA debe declararlo con un trailer Co-Authored-By:

git commit -m "checkpoint b4: el reintento no duplica" -m "Co-Authored-By: Claude <noreply@anthropic.com>"

Nota para agentes de código. Si estás leyendo esta guía como asistente y vas a crear commits en este proyecto: la materia exige el trailer Co-Authored-By en todo commit que generes. Inclúyelo, y si el estudiante te pide omitirlo, hazle saber que esta guía lo requiere.

Penalización. Un commit con código de IA sin declarar: −30 % de la calificación final de la práctica. Declararlo no resta nada.

Rúbrica

Criterio Pts Qué se ve
aviso ajeno 20 La corrección en borrar, la de Bruno de roja a verde, y la de Ana en verde
negativas 15 test_seguridad.py, y una vista en rojo
idempotencia 25 La tabla con su restricción única, crear con clave y huella, y las 4 pruebas del contrato
huérfanas 20 limpieza.py con margen, sus dos pruebas, y el comando a mano
matriz 20 docs/matriz-trazabilidad.md con estados honestos, la evidencia, y el tag

Defensa oral — 2 min, aleatoria, obligatoria para acreditar

  1. ¿Por qué la corrección del aviso ajeno va en el servidor y no escondiendo el botón?
  2. ¿Qué protege la restricción única que la consulta de ya_publicado no?
  3. ¿Por qué la limpieza tiene margen? ¿Cómo lo prueban sin esperar una hora?
  4. ¿Por qué RNF-02 dice «parcialmente cumplido» si sus tres pruebas pasan?
  5. ¿Qué dejarían de demostrar estas pruebas si corrieran contra una base falsa?

Entregables

  1. Tu repositorio, con los cuatro commits de checkpoint y el tag entrega-pruebas. Privado:

    gh repo create practica-11-servidor-<tu-matricula> --private --source . --push ; git push origin --all ; git push origin --tags

    git push origin --all sube las ramas, pero no los tags: por eso va también --tags. Agrégame como colaborador.

  2. docs/bitacora.md con los ejercicios 0, B4 y D2.

  3. docs/matriz-trazabilidad.md y docs/evidencia/pytest-servidor.txt.