Cobre rotas HTTP, proxy, worker assíncrono e barreiras do dev auth
Os 34 testes existentes batiam só na camada de serviço: as rotas, o proxy e o loop do worker não tinham cobertura alguma. A suíte vai de 34 para 102. - `tests/http/`: rotas reais com o caminho real de autenticação, sem subir servidor. Cobre 401 sem sessão, módulo desligado, papel sem permissão, 403 ao tentar gravar workspace de terceiro, e 429 com Retry-After. - `tests/http/proxy.test.ts`: o proxy não propaga query da requisição para o login, o que impediria um returnTo forjado. - `tests/integration/worker.test.ts`: jornada assíncrona pelo loop real. A web enfileira e devolve `queued`, o download falha antes do tick, o tick conclui e os três formatos saem. Também: job sobrevive à perda da fila do Redis, worker relê o contexto e não processa com a flag desligada, TTL expira temporários sem tocar o modelo, e desligamento gracioso. - `tests/unit/dev-auth-guard.test.ts`: matriz de ambientes das três barreiras do login local. Um token de dev válido é recusado quando o ambiente é staging. Espaço em volta de um valor de ADAPTER_ENV é tolerado de propósito, e isso está declarado no teste; valor desconhecido nunca vira development. - `tests/services/`: Postgres e Redis reais. Confere `app_user` sem BYPASSRLS, FORCE RLS em todas as tabelas, isolamento entre workspaces, auditoria não alterável pelo papel da aplicação, e lock entre conexões diferentes. Sem as variáveis de serviço os casos são IGNORADOS, nunca aprovados, e ficam fora de `npm test` para a suíte padrão passar em qualquer máquina. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dea510c3b4
commit
ce11a50091
10 files changed
+1182
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createPostgresDb } from "@/infra/db/postgres";
|
||||
import { migrate, pendingMigrations } from "@/infra/db/migrate";
|
||||
import type { Db } from "@/infra/db/types";
|
||||
|
||||
/**
|
||||
* RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa
|
||||
* PGlite, que é o mesmo motor mas não o mesmo servidor: papel `app_user`,
|
||||
* `force row level security` e `set local role` precisam ser confirmados aqui.
|
||||
*/
|
||||
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
||||
const enabled = Boolean(url);
|
||||
|
||||
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
||||
const BETA = "22222222-2222-4222-8222-222222222222";
|
||||
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const DIEGO = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
|
||||
let db: Db;
|
||||
|
||||
describe.skipIf(!enabled)("PostgreSQL real", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.ADAPTER_APPLY_IDENTITY_SHIM = "1";
|
||||
db = createPostgresDb(url as string);
|
||||
await db.exec(`drop schema if exists public cascade; create schema public;`);
|
||||
await migrate(db, { applyIdentityShim: true });
|
||||
await db.withService(async (query) => {
|
||||
await query.query(
|
||||
`insert into public.profiles (id, display_name, is_superadmin) values ($1,'Ana',false), ($2,'Diego',false)
|
||||
on conflict (id) do nothing`,
|
||||
[ANA, DIEGO],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspaces (id, name) values ($1,'Alpha'), ($2,'Beta') on conflict (id) do nothing`,
|
||||
[ALPHA, BETA],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspace_members (workspace_id, user_id, role) values ($1,$2,'owner'), ($3,$4,'admin')
|
||||
on conflict (workspace_id, user_id) do nothing`,
|
||||
[ALPHA, ANA, BETA, DIEGO],
|
||||
);
|
||||
await query.query(
|
||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||
values ($1,'spreadsheet_adapter',true), ($2,'spreadsheet_adapter',true)
|
||||
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled`,
|
||||
[ALPHA, BETA],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.close();
|
||||
});
|
||||
|
||||
it("aplica todas as migrations e não deixa pendência", async () => {
|
||||
expect(await pendingMigrations(db, { applyIdentityShim: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("cria o papel app_user sem bypass de RLS", async () => {
|
||||
const rows = await db.withService((query) =>
|
||||
query.query<{ rolbypassrls: boolean; rolsuper: boolean }>(
|
||||
`select rolbypassrls, rolsuper from pg_roles where rolname = 'app_user'`,
|
||||
),
|
||||
);
|
||||
expect(rows[0]).toBeDefined();
|
||||
expect(rows[0].rolbypassrls).toBe(false);
|
||||
expect(rows[0].rolsuper).toBe(false);
|
||||
});
|
||||
|
||||
it("força RLS em todas as tabelas do módulo", async () => {
|
||||
const rows = await db.withService((query) =>
|
||||
query.query<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean }>(
|
||||
`select relname, relrowsecurity, relforcerowsecurity from pg_class
|
||||
where relname like 'spreadsheet%' or relname in ('profiles','workspaces','workspace_members','audit_log')`,
|
||||
),
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const row of rows) {
|
||||
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
|
||||
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("não deixa um usuário ver o workspace do outro", async () => {
|
||||
const anaSees = await db.withUser(ANA, (query) =>
|
||||
query.query<{ id: string }>(`select id from public.workspaces`),
|
||||
);
|
||||
expect(anaSees.map((row) => row.id.toLowerCase())).toEqual([ALPHA]);
|
||||
|
||||
const diegoSees = await db.withUser(DIEGO, (query) =>
|
||||
query.query<{ id: string }>(`select id from public.workspaces`),
|
||||
);
|
||||
expect(diegoSees.map((row) => row.id.toLowerCase())).toEqual([BETA]);
|
||||
});
|
||||
|
||||
it("não deixa um usuário ler modelo de outro workspace", async () => {
|
||||
const modelId = "99999999-9999-4999-8999-999999999999";
|
||||
await db.withService((query) =>
|
||||
query.query(
|
||||
`insert into public.spreadsheet_models
|
||||
(id, workspace_id, name, original_filename, format, storage_key, content_sha256, sheet_name, header_row, columns_schema, created_by, status)
|
||||
values ($1,$2,'Modelo Beta','b.xlsx','xlsx',$3,'sha','Plan1',1,'[]'::jsonb,$4,'active')
|
||||
on conflict (id) do nothing`,
|
||||
[modelId, BETA, `workspaces/${BETA}/models/${modelId}/original`, DIEGO],
|
||||
),
|
||||
);
|
||||
const anaSees = await db.withUser(ANA, (query) =>
|
||||
query.query(`select id from public.spreadsheet_models where id = $1`, [modelId]),
|
||||
);
|
||||
expect(anaSees).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("não deixa o papel da aplicação alterar auditoria", async () => {
|
||||
await db.withService((query) =>
|
||||
query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
||||
values ($1,$2,'test.event','workspace',$1,'success')`,
|
||||
[ALPHA, ANA],
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("pula o shim quando a decisão é não aplicar", async () => {
|
||||
const pending = await pendingMigrations(db, { applyIdentityShim: false });
|
||||
expect(pending).not.toContain("0001_platform_shim.sql");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(enabled)("PostgreSQL real", () => {
|
||||
it("PENDENTE: defina ADAPTER_TEST_DATABASE_URL para executar", () => {
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { RedisCoordinator, lockKey, progressKey, queueKey, rateLimitKey } from "@/infra/redis/redis";
|
||||
|
||||
/**
|
||||
* Lock, fila, progresso e contador contra Redis real. A suíte padrão usa o
|
||||
* coordenador em memória, que não atravessa processos — é justamente o que o
|
||||
* lock precisa fazer em homologação com web e worker separados.
|
||||
*/
|
||||
const url = process.env.ADAPTER_TEST_REDIS_URL;
|
||||
const enabled = Boolean(url);
|
||||
|
||||
const coordinators: RedisCoordinator[] = [];
|
||||
|
||||
function coordinator(): RedisCoordinator {
|
||||
const instance = new RedisCoordinator(url as string);
|
||||
coordinators.push(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const instance of coordinators) await instance.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
describe.skipIf(!enabled)("Redis real", () => {
|
||||
it("responde ao ping", async () => {
|
||||
expect(await coordinator().ping()).toBe(true);
|
||||
});
|
||||
|
||||
it("dá o lock a um dono só, entre conexões diferentes", async () => {
|
||||
const key = `${lockKey("teste-" + Date.now())}`;
|
||||
const web = coordinator();
|
||||
const worker = coordinator();
|
||||
expect(await web.acquireLock(key, "web", 30)).toBe(true);
|
||||
expect(await worker.acquireLock(key, "worker", 30)).toBe(false);
|
||||
expect(await worker.ownsLock(key, "web")).toBe(true);
|
||||
await web.releaseLock(key, "web");
|
||||
expect(await worker.acquireLock(key, "worker", 30)).toBe(true);
|
||||
await worker.releaseLock(key, "worker");
|
||||
});
|
||||
|
||||
it("não deixa quem não é dono liberar o lock", async () => {
|
||||
const key = `${lockKey("dono-" + Date.now())}`;
|
||||
const web = coordinator();
|
||||
expect(await web.acquireLock(key, "web", 30)).toBe(true);
|
||||
await web.releaseLock(key, "intruso");
|
||||
expect(await web.ownsLock(key, "web")).toBe(true);
|
||||
await web.releaseLock(key, "web");
|
||||
});
|
||||
|
||||
it("entrega o job enfileirado ao outro processo", async () => {
|
||||
const jobId = `job-${Date.now()}`;
|
||||
const web = coordinator();
|
||||
const worker = coordinator();
|
||||
await web.enqueue(jobId);
|
||||
const dequeued = await worker.dequeue();
|
||||
expect(dequeued).toBe(jobId);
|
||||
});
|
||||
|
||||
it("compartilha progresso entre processos", async () => {
|
||||
const jobId = `progresso-${Date.now()}`;
|
||||
const web = coordinator();
|
||||
const worker = coordinator();
|
||||
await worker.setProgress(jobId, {
|
||||
phase: "processing",
|
||||
label: "Processando",
|
||||
percent: 42,
|
||||
sourceIndex: 0,
|
||||
sourceCount: 1,
|
||||
});
|
||||
const progress = await web.getProgress(jobId);
|
||||
expect(progress?.percent).toBe(42);
|
||||
});
|
||||
|
||||
it("conta a janela de rate limit e expira a chave", async () => {
|
||||
const key = rateLimitKey("process", `teste-${Date.now()}`, 1);
|
||||
const instance = coordinator();
|
||||
expect(await instance.hit(key, 60)).toBe(1);
|
||||
expect(await instance.hit(key, 60)).toBe(2);
|
||||
expect(await instance.hit(key, 60)).toBe(3);
|
||||
});
|
||||
|
||||
it("separa as chaves por aplicativo e ambiente", () => {
|
||||
const previous = process.env.ADAPTER_ENV;
|
||||
try {
|
||||
process.env.ADAPTER_ENV = "staging";
|
||||
const stagingQueue = queueKey();
|
||||
const stagingProgress = progressKey("x");
|
||||
process.env.ADAPTER_ENV = "production";
|
||||
expect(queueKey()).not.toBe(stagingQueue);
|
||||
expect(progressKey("x")).not.toBe(stagingProgress);
|
||||
expect(stagingQueue).toContain("adaptador-planilhas:staging");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.ADAPTER_ENV;
|
||||
else process.env.ADAPTER_ENV = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("recusa com erro de domínio quando o Redis está inacessível", async () => {
|
||||
const broken = new RedisCoordinator("redis://127.0.0.1:1");
|
||||
await expect(broken.enqueue("x")).rejects.toMatchObject({ code: "REDIS_UNAVAILABLE" });
|
||||
expect(await broken.ping()).toBe(false);
|
||||
await broken.close().catch(() => undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(enabled)("Redis real", () => {
|
||||
it("PENDENTE: defina ADAPTER_TEST_REDIS_URL para executar", () => {
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user