@jun/soul/testing
Testing
Helpers para montar datos y una bateria de chequeos de seguridad.
Que trae#
Lo necesario para probar un proyecto sobre soul con
@cloudflare/vitest-pool-workers: aplicar migraciones, crear usuarios y organizaciones,
falsificar cookies de sesion y verificar el aislamiento entre inquilinos.
Migraciones y limpieza#
applySql(db, sql)- Aplica un archivo SQL partiendo las sentencias. Necesario porque
db.exec()no tolera SQL multilinea ni comentarios. SOUL_TABLES- Las tablas del core, en orden seguro de borrado.
resetSoulTables(db, extraTables?)- Vacia el core entre tests. Las tablas hijas van en
extraTablesy se borran primero.
import { applySql, resetSoulTables } from "@jun/soul/testing";
import coreSql from "@jun/soul/migrations/0001_soul_core.sql?raw";
import misTablas from "../migrations/0002_negocio.sql?raw";
beforeAll(async () => {
await applySql(env.DB, coreSql);
await applySql(env.DB, misTablas);
});
beforeEach(async () => {
await resetSoulTables(env.DB, ["facturas"]); // hijas primero
});
Datos de prueba#
createTestUser(db, { email, name?, isAdmin? })- Crea un usuario y devuelve su id. No crea organizacion.
createTestOrg(db, { name?, kind?, plan? })- Crea una organizacion.
addTestMember(db, orgId, userId, role?)- Agrega la membresia.
sessionCookieFor(secret, userId, email, cookieName?)- Fabrica una cookie de sesion valida, para saltarse el OAuth.
Chequeos de seguridad#
runSecurityChecks corre de una vez los cuatro errores que mas se repiten:
| Chequeo | Que espera |
|---|---|
| Anonimo en ruta protegida | 302 al login |
| Cookie falsificada | 302 al login (la firma no valida) |
| Usuario normal en ruta de admin | 403 |
| Recurso de otra organizacion | 404 (si defines createResource) |
import { runSecurityChecks } from "@jun/soul/testing";
it("las defensas basicas estan puestas", async () => {
const checks = await runSecurityChecks({
fetch: SELF.fetch.bind(SELF),
db: env.DB,
sessionSecret: env.SESSION_SECRET,
protectedPath: "/facturas",
adminPaths: ["/admin/users", "/admin/orgs"],
createResource: async (db, orgId) => {
const r = await db.prepare(
"INSERT INTO facturas (org_id, title) VALUES (?, 'x') RETURNING id",
).bind(orgId).first<{ id: number }>();
return `/facturas/${r!.id}`;
},
});
for (const c of checks) expect(c.ok, `${c.name}: ${c.detail}`).toBe(true);
});
Configuracion de vitest#
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.jsonc" },
miniflare: {
bindings: {
SESSION_SECRET: "secreto-solo-de-tests",
GOOGLE_CLIENT_ID: "test-client-id",
GOOGLE_CLIENT_SECRET: "test-client-secret",
},
},
},
},
},
});
Escenario multi-organizacion#
import { createTestUser, createTestOrg, addTestMember, sessionCookieFor } from "@jun/soul/testing";
let cookieAna: string, cookieBeto: string, orgA: number, orgB: number;
beforeEach(async () => {
await resetSoulTables(env.DB, ["facturas"]);
const ana = await createTestUser(env.DB, { email: "ana@a.dev", name: "Ana" });
orgA = await createTestOrg(env.DB, { name: "Empresa A", plan: "pro" });
await addTestMember(env.DB, orgA, ana, "owner");
cookieAna = await sessionCookieFor(env.SESSION_SECRET, ana, "ana@a.dev");
const beto = await createTestUser(env.DB, { email: "beto@b.dev", name: "Beto" });
orgB = await createTestOrg(env.DB, { name: "Empresa B" });
await addTestMember(env.DB, orgB, beto, "owner");
cookieBeto = await sessionCookieFor(env.SESSION_SECRET, beto, "beto@b.dev");
});
it("Beto no ve las facturas de Ana", async () => {
const r = await env.DB.prepare(
"INSERT INTO facturas (org_id, title) VALUES (?, 'Secreta') RETURNING id",
).bind(orgA).first<{ id: number }>();
const res = await SELF.fetch(`http://localhost/facturas/${r!.id}`, {
headers: { Cookie: cookieBeto },
});
// 404, no 403: un 403 confirmaria que la factura existe.
expect(res.status).toBe(404);
});
Permisos por rol#
it("un member no puede borrar", async () => {
const uid = await createTestUser(env.DB, { email: "member@a.dev" });
await addTestMember(env.DB, orgA, uid, "member");
const cookie = await sessionCookieFor(env.SESSION_SECRET, uid, "member@a.dev");
const res = await SELF.fetch(`http://localhost/facturas/1/delete`, {
method: "POST", headers: { Cookie: cookie }, redirect: "manual",
});
expect(res.status).toBe(403);
});
it("una accion no declarada niega siempre (fail-closed)", async () => {
const res = await SELF.fetch("http://localhost/facturas/accion-inventada", {
headers: { Cookie: cookieAna },
});
expect(res.status).toBe(403);
});
Limites de plan#
it("el plan free corta al segundo proyecto", async () => {
const org = await createTestOrg(env.DB, { name: "Chica", plan: "free" });
const uid = await createTestUser(env.DB, { email: "free@x.dev" });
await addTestMember(env.DB, org, uid, "owner");
const cookie = await sessionCookieFor(env.SESSION_SECRET, uid, "free@x.dev");
const crear = () => SELF.fetch("http://localhost/proyectos", {
method: "POST", headers: { Cookie: cookie }, body: new URLSearchParams({ title: "P" }),
redirect: "manual",
});
expect((await crear()).status).toBe(302);
expect((await crear()).status).toBe(403);
});
Gotcha: el cache de miniflare se cuelga#
Sintoma: un test que pasa solo pero cuelga junto a los demas. La salida es no dejar el
put pendiente antes de invalidar; verifica el efecto por base de datos:
// MAL: el fetch deja un put pendiente y el update de abajo invalida la misma clave
await SELF.fetch("http://localhost/m/hola");
await editar(...);
const res = await SELF.fetch("http://localhost/m/hola"); // se cuelga
// BIEN: se comprueba el efecto en la base y con un 404 de la URL vieja
await editar(...);
const row = await env.DB.prepare("SELECT slug FROM memoriales WHERE id = ?").bind(id).first();
expect(row.slug).toBe("nuevo");
expect((await SELF.fetch("http://localhost/m/viejo")).status).toBe(404);
Probar concurrencia de verdad#
Un test de carrera solo sirve si de verdad falla sin el arreglo. Vale la pena revertir la correccion una vez y comprobar que el test se pone rojo.
it("las activaciones simultaneas no pasan del limite", async () => {
const { slug, key } = await seedProduct({ maxActivations: 2 });
const results = await Promise.all(
["d1", "d2", "d3", "d4", "d5"].map((fp) =>
post("/activate", { product: slug, license_key: key, fingerprint: fp }),
),
);
expect(results.filter((r) => r.status === 200)).toHaveLength(2);
expect(results.filter((r) => r.status === 403)).toHaveLength(3);
});