From ce11a50091723157d4b020a444ddc6ad197cfc2e Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:39:43 -0300 Subject: [PATCH] =?UTF-8?q?Cobre=20rotas=20HTTP,=20proxy,=20worker=20ass?= =?UTF-8?q?=C3=ADncrono=20e=20barreiras=20do=20dev=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/http/helpers.ts | 69 ++++++++++ tests/http/proxy.test.ts | 55 ++++++++ tests/http/routes.test.ts | 192 ++++++++++++++++++++++++++ tests/integration/worker.test.ts | 220 ++++++++++++++++++++++++++++++ tests/services/postgres.test.ts | 136 ++++++++++++++++++ tests/services/redis.test.ts | 110 +++++++++++++++ tests/unit/dev-auth-guard.test.ts | 187 +++++++++++++++++++++++++ tests/unit/staging-policy.test.ts | 184 +++++++++++++++++++++++++ vitest.config.ts | 3 + vitest.services.config.ts | 26 ++++ 10 files changed, 1182 insertions(+) create mode 100644 tests/http/helpers.ts create mode 100644 tests/http/proxy.test.ts create mode 100644 tests/http/routes.test.ts create mode 100644 tests/integration/worker.test.ts create mode 100644 tests/services/postgres.test.ts create mode 100644 tests/services/redis.test.ts create mode 100644 tests/unit/dev-auth-guard.test.ts create mode 100644 tests/unit/staging-policy.test.ts create mode 100644 vitest.services.config.ts diff --git a/tests/http/helpers.ts b/tests/http/helpers.ts new file mode 100644 index 0000000..6931e3b --- /dev/null +++ b/tests/http/helpers.ts @@ -0,0 +1,69 @@ +import { vi } from "vitest"; +import type { Db } from "@/infra/db/types"; +import { MemoryCoordinator } from "@/infra/redis/memory"; +import { MemoryStorage } from "@/infra/storage/memory"; +import { signDevSession } from "@/server/auth/dev-session"; + +export type FakeCookie = { name: string; value: string }; + +/** + * Jar de cookies no formato que `next/headers` entrega. Permite exercitar as + * rotas reais, com o caminho real de autenticação, sem subir um servidor. + */ +export class FakeJar { + private readonly store = new Map(); + + get(name: string): FakeCookie | undefined { + const value = this.store.get(name); + return value === undefined ? undefined : { name, value }; + } + + getAll(): FakeCookie[] { + return [...this.store.entries()].map(([name, value]) => ({ name, value })); + } + + set(name: string, value: string): void { + this.store.set(name, value); + } + + delete(name: string): void { + this.store.delete(name); + } + + has(name: string): boolean { + return this.store.has(name); + } +} + +export const jar = new FakeJar(); + +/** Sessão local assinada. Só existe porque os testes rodam em development. */ +export function signIn(userId: string, workspaceId: string): void { + jar.set("adapter_dev_session", signDevSession(userId, workspaceId)); +} + +export function signOut(): void { + for (const cookie of jar.getAll()) jar.delete(cookie.name); +} + +/** Dependências do módulo em memória, para não escrever em disco nos testes. */ +export function makeRuntimeDeps(db: Db) { + const storage = new MemoryStorage(); + const coordinator = new MemoryCoordinator(); + return { + deps: { db, storage, coordinator, ttlSeconds: 3600 }, + storage, + coordinator, + }; +} + +export function installNextHeadersMock(): void { + vi.mock("next/headers", () => ({ + cookies: async () => jar, + headers: async () => new Headers(), + })); +} + +export async function readJson(response: Response): Promise { + return (await response.json()) as T; +} diff --git a/tests/http/proxy.test.ts b/tests/http/proxy.test.ts new file mode 100644 index 0000000..8322b66 --- /dev/null +++ b/tests/http/proxy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { proxy, config } from "@/proxy"; + +function request(path: string, cookies: Record = {}): NextRequest { + const next = new NextRequest(new URL(`http://localhost${path}`)); + for (const [name, value] of Object.entries(cookies)) next.cookies.set(name, value); + return next; +} + +describe("proxy", () => { + it("cobre as áreas protegidas e nada além", () => { + expect(config.matcher).toContain("/adaptador/:path*"); + expect(config.matcher).toContain("/api/spreadsheet-adapter/:path*"); + expect(config.matcher).toContain("/selecionar-workspace"); + }); + + it("redireciona a página protegida ao login preservando o destino", () => { + const response = proxy(request("/adaptador/operacao")); + expect(response.status).toBe(307); + const location = new URL(response.headers.get("location") as string); + expect(location.pathname).toBe("/login"); + expect(location.searchParams.get("next")).toBe("/adaptador/operacao"); + }); + + it("não propaga query da requisição original para o login", () => { + const next = new NextRequest(new URL("http://localhost/adaptador?returnTo=https://evil.example")); + const response = proxy(next); + const location = new URL(response.headers.get("location") as string); + expect(location.searchParams.get("returnTo")).toBeNull(); + expect(location.searchParams.get("next")).toBe("/adaptador"); + }); + + it("responde 401 em JSON na API sem sessão", async () => { + const response = proxy(request("/api/spreadsheet-adapter/models")); + expect(response.status).toBe(401); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("UNAUTHENTICATED"); + }); + + it("deixa passar com cookie do Supabase e com a sessão local", () => { + expect(proxy(request("/adaptador", { "sb-projeto-auth-token": "abc" })).status).toBe(200); + expect(proxy(request("/adaptador", { adapter_dev_session: "abc" })).status).toBe(200); + }); + + it("não interfere em rota pública nem no health check", () => { + expect(proxy(request("/")).status).toBe(200); + expect(proxy(request("/api/health/live")).status).toBe(200); + expect(proxy(request("/login")).status).toBe(200); + }); + + it("protege a tela de escolha de workspace", () => { + expect(proxy(request("/selecionar-workspace")).status).toBe(307); + }); +}); diff --git a/tests/http/routes.test.ts b/tests/http/routes.test.ts new file mode 100644 index 0000000..a1cdbc7 --- /dev/null +++ b/tests/http/routes.test.ts @@ -0,0 +1,192 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createReadyDb, IDS } from "../helpers/world"; +import { FakeJar, jar, makeRuntimeDeps, signIn, signOut } from "./helpers"; +import type { Db } from "@/infra/db/types"; + +// Autenticação local assinada: os testes rodam como development. +process.env.ADAPTER_ENV = "development"; +process.env.ADAPTER_DEV_AUTH = "1"; +process.env.ADAPTER_DEV_AUTH_SECRET = "segredo-de-teste"; + +let db: Db; +let runtime: ReturnType; + +vi.mock("next/headers", () => ({ + cookies: async () => jar as unknown as FakeJar, + headers: async () => new Headers(), +})); + +vi.mock("@/infra/db/client", () => ({ + getDb: async () => db, + probeDb: async () => { + await db.withService((query) => query.query("select 1 as ok")); + }, +})); + +vi.mock("@/server/spreadsheet-adapter/runtime", () => ({ + runtimeDeps: async () => runtime.deps, +})); + +beforeAll(async () => { + db = await createReadyDb(); + runtime = makeRuntimeDeps(db); +}); + +beforeEach(() => { + signOut(); +}); + +describe("rotas de saúde", () => { + it("liveness responde sem tocar em dependência", async () => { + const { GET } = await import("@/app/api/health/live/route"); + const response = GET(); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: string; environment: string }; + expect(body.status).toBe("live"); + expect(body.environment).toBe("development"); + }); + + it("readiness reporta cada dependência e não devolve connection string", async () => { + const { GET } = await import("@/app/api/health/ready/route"); + const response = await GET(); + const body = (await response.json()) as { + status: string; + checks: Record; + configIssues: string[]; + }; + expect(body.checks.database).toBe("ok"); + expect(body.checks.migrations).toBe("ok"); + expect(body.status).toBe("ready"); + expect(JSON.stringify(body)).not.toContain("postgres://"); + expect(body.configIssues).toEqual([]); + }); +}); + +describe("autenticação das rotas do módulo", () => { + it("recusa sem sessão", async () => { + const { GET } = await import("@/app/api/spreadsheet-adapter/models/route"); + const response = await GET(); + expect(response.status).toBe(401); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("UNAUTHENTICATED"); + }); + + it("responde com sessão válida no workspace com o módulo ligado", async () => { + signIn(IDS.ana, IDS.alpha); + const { GET } = await import("@/app/api/spreadsheet-adapter/models/route"); + const response = await GET(); + expect(response.status).toBe(200); + const body = (await response.json()) as { models: unknown[] }; + expect(Array.isArray(body.models)).toBe(true); + }); + + it("recusa a configuração para quem não é owner nem admin", async () => { + signIn(IDS.bruno, IDS.alpha); + const { PATCH } = await import("@/app/api/spreadsheet-adapter/settings/route"); + const response = await PATCH( + new Request("http://localhost/api/spreadsheet-adapter/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowNonAdminModelUpload: true }), + }), + ); + expect(response.status).toBeGreaterThanOrEqual(400); + const body = (await response.json()) as { error: { code: string } }; + expect(["FORBIDDEN", "WORKSPACE_FORBIDDEN", "FEATURE_DISABLED"]).toContain(body.error.code); + }); + + it("bloqueia o módulo desligado no workspace", async () => { + signIn(IDS.diego, IDS.beta); + const { GET } = await import("@/app/api/spreadsheet-adapter/models/route"); + const response = await GET(); + expect(response.status).toBeGreaterThanOrEqual(400); + }); +}); + +describe("seleção de workspace", () => { + it("lista só as associações reais do usuário", async () => { + signIn(IDS.eva, IDS.beta); + const { GET } = await import("@/app/api/auth/workspace/route"); + const response = await GET(); + expect(response.status).toBe(200); + const body = (await response.json()) as { memberships: { workspaceId: string }[] }; + expect(body.memberships.map((item) => item.workspaceId)).toEqual([IDS.beta]); + }); + + it("recusa gravar workspace do qual o usuário não é membro", async () => { + signIn(IDS.diego, IDS.beta); + const { POST } = await import("@/app/api/auth/workspace/route"); + const response = await POST( + new Request("http://localhost/api/auth/workspace", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId: IDS.alpha }), + }), + ); + expect(response.status).toBe(403); + expect(jar.has("elo_pro_workspace_id")).toBe(false); + }); + + it("grava a preferência quando a associação existe", async () => { + signIn(IDS.ana, IDS.alpha); + const { POST } = await import("@/app/api/auth/workspace/route"); + const response = await POST( + new Request("http://localhost/api/auth/workspace", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId: IDS.alpha }), + }), + ); + expect(response.status).toBe(200); + expect(jar.get("elo_pro_workspace_id")?.value).toBe(IDS.alpha); + }); + + it("recusa sem sessão", async () => { + const { POST } = await import("@/app/api/auth/workspace/route"); + const response = await POST( + new Request("http://localhost/api/auth/workspace", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId: IDS.alpha }), + }), + ); + expect(response.status).toBe(401); + }); +}); + +describe("login de homologação", () => { + it("não existe fora de staging", async () => { + const { POST } = await import("@/app/api/auth/staging/session/route"); + const response = await POST( + new Request("http://localhost/api/auth/staging/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "a@b.c", password: "x" }), + }), + ); + expect(response.status).toBe(404); + }); +}); + +describe("rate limiting nas rotas", () => { + it("devolve 429 com Retry-After ao estourar a janela", async () => { + signIn(IDS.ana, IDS.alpha); + const { createCoordinator } = await import("@/infra/redis"); + const { RATE_LIMITS, windowStart } = await import("@/server/rate-limit"); + const { rateLimitKey } = await import("@/infra/redis/redis"); + + const rule = RATE_LIMITS.read; + const key = rateLimitKey("read", `${IDS.alpha}:${IDS.ana}`, windowStart(rule)); + const coordinator = createCoordinator(); + for (let index = 0; index < rule.limit; index += 1) { + await coordinator.hit(key, rule.windowSeconds); + } + + const { GET } = await import("@/app/api/spreadsheet-adapter/models/route"); + const response = await GET(); + expect(response.status).toBe(429); + expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("RATE_LIMITED"); + }); +}); diff --git a/tests/integration/worker.test.ts b/tests/integration/worker.test.ts new file mode 100644 index 0000000..a634871 --- /dev/null +++ b/tests/integration/worker.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { addSource, createJob, createModel, saveMappings, startProcess } from "@/server/spreadsheet-adapter/service"; +import { downloadOutput } from "@/server/spreadsheet-adapter/process-job"; +import { createWorker } from "@/server/spreadsheet-adapter/worker-loop"; +import { lockKey } from "@/infra/redis/redis"; +import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world"; + +/** + * Jornada assíncrona pelo loop real do worker: a web enfileira e devolve, e o + * processamento acontece depois, no tick. É o mesmo código que o processo do + * contêiner executa. O que estes casos não cobrem é a fronteira de processo e o + * Redis real — isso está em `tests/services/redis.test.ts` e no CI. + */ + +const previous = { mode: process.env.ADAPTER_JOB_MODE }; + +beforeEach(() => { + // Modo worker: `startProcess` só enfileira e sai. + process.env.ADAPTER_JOB_MODE = "worker"; +}); + +afterEach(() => { + if (previous.mode === undefined) delete process.env.ADAPTER_JOB_MODE; + else process.env.ADAPTER_JOB_MODE = previous.mode; +}); + +type World = Awaited>; + +async function prepare() { + const db = await createReadyDb(); + const deps = depsOf(db); + const ana = ctxOf("ana", "alpha", "owner", true); + const model = await createModel(deps, ana, { + name: "Clientes", + filename: "modelo.csv", + bytes: Buffer.from("Nome,Valor\n", "utf8"), + requiredColumns: ["Nome", "Valor"], + }); + const modelBytes = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`); + const job = await createJob(deps, ana, model.id, "job-async"); + const added = await addSource(deps, ana, job.id, { + filename: "fonte.csv", + bytes: Buffer.from("Nome,Valor\nAna,10\nBruno,20\n", "utf8"), + }); + const sourceId = added.job.sources[0].id; + await saveMappings(deps, ana, job.id, sourceId, [ + { sourceColumn: "Nome", modelColumn: "Nome" }, + { sourceColumn: "Valor", modelColumn: "Valor" }, + ]); + return { db, deps, ana, model, modelBytes, job, sourceId }; +} + +async function statusOf(world: World, jobId: string): Promise { + const rows = await world.db.withService((query) => + query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [jobId]), + ); + return rows[0]?.status ?? "ausente"; +} + +describe("jornada assíncrona pelo worker", () => { + it("enfileira na web, processa no tick e entrega os três formatos", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + + const queued = await startProcess(deps, ana, job.id, sourceId, "proc-async"); + expect(queued.status).toBe("queued"); + // Nada foi processado ainda: a web só enfileirou. + await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "NOT_FOUND" }); + + const worker = createWorker(deps, { heartbeatPath: null }); + const result = await worker.tick(); + + expect(result.processed).toEqual([job.id]); + expect(await statusOf(world, job.id)).toBe("completed"); + + const csv = await downloadOutput(deps, ana, job.id, sourceId, "csv"); + expect(csv.body.toString("utf8")).toContain("Ana"); + const xlsx = await downloadOutput(deps, ana, job.id, sourceId, "xlsx"); + expect(xlsx.body.subarray(0, 2).toString()).toBe("PK"); + const pdf = await downloadOutput(deps, ana, job.id, sourceId, "pdf"); + expect(pdf.body.subarray(0, 4).toString()).toBe("%PDF"); + + const after = await deps.storage.get(`workspaces/${IDS.alpha}/models/${world.model.id}/original`); + expect(after.equals(world.modelBytes)).toBe(true); + }); + + it("libera o lock do usuário ao concluir, permitindo o próximo job", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + await startProcess(deps, ana, job.id, sourceId, "proc-lock"); + await createWorker(deps, { heartbeatPath: null }).tick(); + + // Lock livre: outro processamento consegue adquirir. + expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "outro-job", 30)).toBe(true); + }); + + it("encontra o job pela varredura do banco quando a fila do Redis se perdeu", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + await startProcess(deps, ana, job.id, sourceId, "proc-sem-fila"); + + // Simula Redis reiniciado: a fila some, o estado durável no Postgres fica. + while (await deps.coordinator.dequeue()) { + /* drena */ + } + expect(await deps.coordinator.dequeue()).toBeNull(); + + const result = await createWorker(deps, { heartbeatPath: null }).tick(); + expect(result.processed).toEqual([job.id]); + expect(await statusOf(world, job.id)).toBe("completed"); + }); + + it("relê o contexto do dono e não processa com o módulo desligado", async () => { + const world = await prepare(); + const { db, deps, ana, job, sourceId } = world; + await startProcess(deps, ana, job.id, sourceId, "proc-flag"); + + // A flag cai entre o enfileiramento e o tick. + await db.withService((query) => + query.query( + `update public.workspace_module_flags set enabled = false where workspace_id = $1 and module_key = 'spreadsheet_adapter'`, + [IDS.alpha], + ), + ); + + const result = await createWorker(deps, { heartbeatPath: null }).tick(); + expect(result.processed).toEqual([]); + expect(await statusOf(world, job.id)).not.toBe("completed"); + await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toBeTruthy(); + }); + + it("expira temporários no tick e preserva o modelo", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + await startProcess(deps, ana, job.id, sourceId, "proc-ttl"); + await createWorker(deps, { heartbeatPath: null }).tick(); + await downloadOutput(deps, ana, job.id, sourceId, "csv"); + + // Avança além do TTL e roda o tick de novo. + const expirado = { ...deps, now: () => new Date("2027-01-01T00:00:00.000Z") }; + const result = await createWorker(expirado, { heartbeatPath: null }).tick(); + expect(result.cleaned).toBeGreaterThan(0); + await expect(downloadOutput(expirado, ana, job.id, sourceId, "csv")).rejects.toBeTruthy(); + + const model = await deps.storage.get(`workspaces/${IDS.alpha}/models/${world.model.id}/original`); + expect(model.equals(world.modelBytes)).toBe(true); + }); + + it("grava heartbeat ao fim do tick", async () => { + const world = await prepare(); + const escritas: string[] = []; + const worker = createWorker(world.deps, { + heartbeatPath: "/tmp/teste-heartbeat", + writeHeartbeat: async (target) => { + escritas.push(target); + }, + }); + await worker.tick(); + expect(escritas).toEqual(["/tmp/teste-heartbeat"]); + }); +}); + +describe("desligamento gracioso do worker", () => { + it("para de aceitar trabalho novo depois do shutdown", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + const worker = createWorker(deps, { heartbeatPath: null }); + + expect(worker.isRunning()).toBe(true); + expect(await worker.shutdown(1000)).toBe(true); + expect(worker.isRunning()).toBe(false); + + await startProcess(deps, ana, job.id, sourceId, "proc-pos-shutdown"); + const result = await worker.tick(); + expect(result.processed).toEqual([]); + expect(await statusOf(world, job.id)).toBe("queued"); + }); + + it("espera o tick em andamento e não perde o job que não começou", async () => { + const world = await prepare(); + const { deps, ana, job, sourceId } = world; + await startProcess(deps, ana, job.id, sourceId, "proc-espera"); + + const worker = createWorker(deps, { heartbeatPath: null }); + const inFlight = worker.tick(); + expect(worker.isBusy()).toBe(true); + + // O encerramento espera o tick corrente sair: é essa espera que dá tempo ao + // `finally` do processamento liberar o lock de um job já iniciado. + const clean = await worker.shutdown(10_000); + expect(clean).toBe(true); + expect(worker.isBusy()).toBe(false); + await inFlight; + + // O job que ainda não havia começado continua `queued`: nada pela metade. + expect(await statusOf(world, job.id)).toBe("queued"); + // O lock continua reservado para ESTE job, não vazou para outro dono. Em + // modo worker ele é adquirido no enfileiramento, com TTL de 120s, e + // `runProcess` o reaproveita no tick seguinte. + expect(await deps.coordinator.ownsLock(lockKey(ana.userId), job.id)).toBe(true); + expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "outro-job", 5)).toBe(false); + + // O worker seguinte conclui o mesmo job, como aconteceria após um restart. + const result = await createWorker(deps, { heartbeatPath: null }).tick(); + expect(result.processed).toEqual([job.id]); + expect(await statusOf(world, job.id)).toBe("completed"); + // Concluído, o lock é liberado pelo `finally` do processamento. + expect(await deps.coordinator.acquireLock(lockKey(ana.userId), "sonda", 5)).toBe(true); + }); + + it("reporta encerramento sujo quando a carência estoura", async () => { + const world = await prepare(); + const worker = createWorker(world.deps, { heartbeatPath: null }); + const inFlight = worker.tick(); + expect(worker.isBusy()).toBe(true); + // Carência zero: não espera nada e reporta que o tick não terminou. + expect(await worker.shutdown(0)).toBe(false); + await inFlight; + }); +}); diff --git a/tests/services/postgres.test.ts b/tests/services/postgres.test.ts new file mode 100644 index 0000000..4f5d0f5 --- /dev/null +++ b/tests/services/postgres.test.ts @@ -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); + }); +}); diff --git a/tests/services/redis.test.ts b/tests/services/redis.test.ts new file mode 100644 index 0000000..28276ea --- /dev/null +++ b/tests/services/redis.test.ts @@ -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); + }); +}); diff --git a/tests/unit/dev-auth-guard.test.ts b/tests/unit/dev-auth-guard.test.ts new file mode 100644 index 0000000..8b2519c --- /dev/null +++ b/tests/unit/dev-auth-guard.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { adapterEnv, collectConfigIssues, stagingAuthEnabled } from "@/config/env"; +import { devAuthEnabled } from "@/server/auth/dev-session"; + +/** + * O login local sem senha não pode existir em homologação pública, em nenhuma + * combinação de variáveis. Há três barreiras independentes e esta suíte cobre + * as três, na matriz inteira de ambientes. + */ + +const original = { + ADAPTER_ENV: process.env.ADAPTER_ENV, + NODE_ENV: process.env.NODE_ENV, + ADAPTER_DEV_AUTH: process.env.ADAPTER_DEV_AUTH, + ADAPTER_DEV_AUTH_SECRET: process.env.ADAPTER_DEV_AUTH_SECRET, +}; + +function setEnv(values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +afterEach(() => { + setEnv(original); +}); + +describe("barreira 1: devAuthEnabled", () => { + const casos: { nome: string; env: Record; esperado: boolean }[] = [ + { + nome: "development com flag e segredo", + env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: true, + }, + { + nome: "development sem a flag", + env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "0", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "development sem segredo", + env: { ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: undefined }, + esperado: false, + }, + { + nome: "staging com flag e segredo", + env: { ADAPTER_ENV: "staging", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "production com flag e segredo", + env: { ADAPTER_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "ADAPTER_ENV ausente com NODE_ENV=production", + env: { ADAPTER_ENV: undefined, NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "ADAPTER_ENV com valor inválido", + env: { ADAPTER_ENV: "homolog", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "ADAPTER_ENV com maiúsculas não é aceito como development", + env: { ADAPTER_ENV: "Development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + { + nome: "staging mesmo com NODE_ENV=development", + env: { ADAPTER_ENV: "staging", NODE_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }, + esperado: false, + }, + ]; + + for (const caso of casos) { + it(`${caso.nome} → ${caso.esperado ? "ligado" : "desligado"}`, () => { + setEnv({ ADAPTER_ENV: undefined, NODE_ENV: "test", ADAPTER_DEV_AUTH: undefined, ADAPTER_DEV_AUTH_SECRET: undefined }); + setEnv(caso.env); + expect(devAuthEnabled()).toBe(caso.esperado); + }); + } + + it("um valor inesperado de ADAPTER_ENV nunca resolve para development", () => { + for (const valor of ["prod", "hml", "homolog", "homologacao", "stage", "staging ", "DEV", "Development", "dev"]) { + setEnv({ ADAPTER_ENV: valor, NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }); + expect(adapterEnv(), `valor ${JSON.stringify(valor)}`).not.toBe("development"); + expect(devAuthEnabled(), `valor ${JSON.stringify(valor)}`).toBe(false); + } + }); + + it("espaço em volta de um valor válido é tolerado, de propósito", () => { + // Espaço sobrando em arquivo de env é erro de digitação comum, e o valor + // ainda é inequívoco. O que nunca acontece é um valor DESCONHECIDO virar + // development: o caso acima cobre isso, e `ADAPTER_ENV` vazio cai em + // NODE_ENV. Reconhecer " staging " como staging é o comportamento seguro. + setEnv({ ADAPTER_ENV: " staging ", NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }); + expect(adapterEnv()).toBe("staging"); + expect(devAuthEnabled()).toBe(false); + + setEnv({ ADAPTER_ENV: " development ", NODE_ENV: "production", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }); + expect(adapterEnv()).toBe("development"); + expect(devAuthEnabled()).toBe(true); + }); +}); + +describe("barreira 2: o start é recusado", () => { + const base = { + ADAPTER_ENV: "staging", + NEXT_PUBLIC_SUPABASE_URL: "https://p.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon", + SUPABASE_SERVICE_ROLE_KEY: "service", + SUPABASE_STORAGE_BUCKET: "bucket-hml", + DATABASE_URL: "postgres://host/db", + REDIS_URL: "redis://host:6379", + SPREADSHEET_TEMP_TTL_SECONDS: "86400", + ADAPTER_JOB_MODE: "worker", + ADAPTER_APPLY_IDENTITY_SHIM: "1", + ADAPTER_STAGING_AUTH: "1", + }; + + it("staging com ADAPTER_DEV_AUTH=1 não passa na validação", () => { + const issues = collectConfigIssues({ ...base, ADAPTER_DEV_AUTH: "1" }); + expect(issues.map((issue) => issue.variable)).toContain("ADAPTER_DEV_AUTH"); + }); + + it("production com ADAPTER_DEV_AUTH=1 não passa na validação", () => { + const issues = collectConfigIssues({ + ...base, + ADAPTER_ENV: "production", + ADAPTER_STAGING_AUTH: "0", + SENTRY_DSN: "https://k@sentry.io/1", + ELO_PRO_LOGIN_URL: "https://login.elo.example", + ADAPTER_DEV_AUTH: "1", + }); + expect(issues.map((issue) => issue.variable)).toContain("ADAPTER_DEV_AUTH"); + }); + + it("staging sem a flag de dev passa", () => { + expect(collectConfigIssues({ ...base, ADAPTER_DEV_AUTH: "0" })).toEqual([]); + }); +}); + +describe("barreira 3: o provedor não entra no caminho de código", () => { + it("fora de development só o Supabase resolve identidade", async () => { + setEnv({ ADAPTER_ENV: "staging", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }); + const { activeProviders } = await import("@/server/auth/identity-provider"); + expect(activeProviders().map((provider) => provider.name)).toEqual(["supabase"]); + }); + + it("em development o provedor local vem primeiro", async () => { + setEnv({ ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "s" }); + const { activeProviders } = await import("@/server/auth/identity-provider"); + expect(activeProviders().map((provider) => provider.name)).toEqual(["dev", "supabase"]); + }); + + it("o provedor local recusa o token mesmo se for chamado em staging", async () => { + setEnv({ ADAPTER_ENV: "development", ADAPTER_DEV_AUTH: "1", ADAPTER_DEV_AUTH_SECRET: "segredo" }); + const { signDevSession } = await import("@/server/auth/dev-session"); + const token = signDevSession("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "11111111-1111-4111-8111-111111111111"); + + setEnv({ ADAPTER_ENV: "staging" }); + const { devIdentityProvider } = await import("@/server/auth/identity-provider"); + const jar = { + get: (name: string) => (name === "adapter_dev_session" ? { name, value: token } : undefined), + getAll: () => [{ name: "adapter_dev_session", value: token }], + set: () => undefined, + }; + expect(await devIdentityProvider.resolve(jar as never)).toBeNull(); + expect(devIdentityProvider.hadSessionHint(jar as never)).toBe(false); + }); +}); + +describe("login de homologação não vaza para produção", () => { + it("stagingAuthEnabled é falso em development e production", () => { + const base = { + ADAPTER_STAGING_AUTH: "1", + NEXT_PUBLIC_SUPABASE_URL: "https://p.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon", + }; + expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "staging" })).toBe(true); + expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "production" })).toBe(false); + expect(stagingAuthEnabled({ ...base, ADAPTER_ENV: "development" })).toBe(false); + }); +}); diff --git a/tests/unit/staging-policy.test.ts b/tests/unit/staging-policy.test.ts new file mode 100644 index 0000000..f5bb96a --- /dev/null +++ b/tests/unit/staging-policy.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + adapterEnv, + assertDeployedEnv, + collectConfigIssues, + identityShimEnabled, + isDeployedEnv, + jobRunsInline, + requireDatabaseUrl, + resourceNamespace, + stagingAuthEnabled, +} from "@/config/env"; +import { isShimMigration } from "@/infra/db/migrate"; +import { resolveWorkspace, type Membership } from "@/server/auth/workspace"; + +const ALPHA = "11111111-1111-4111-8111-111111111111"; +const BETA = "22222222-2222-4222-8222-222222222222"; + +function membership(workspaceId: string, name: string): Membership { + return { workspaceId, workspaceName: name, role: "owner", moduleEnabled: true }; +} + +const stagingBase = { + ADAPTER_ENV: "staging", + NODE_ENV: "production", + NEXT_PUBLIC_SUPABASE_URL: "https://projeto.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon", + SUPABASE_SERVICE_ROLE_KEY: "service", + SUPABASE_STORAGE_BUCKET: "adaptador-planilhas-hml", + DATABASE_URL: "postgres://host/db", + REDIS_URL: "redis://host:6379", + SPREADSHEET_TEMP_TTL_SECONDS: "86400", + ADAPTER_JOB_MODE: "worker", + ADAPTER_APPLY_IDENTITY_SHIM: "1", + ADAPTER_STAGING_AUTH: "1", +}; + +describe("ambiente lógico", () => { + it("separa development, staging e production", () => { + expect(adapterEnv({ ADAPTER_ENV: "staging", NODE_ENV: "production" })).toBe("staging"); + expect(adapterEnv({ NODE_ENV: "production" })).toBe("production"); + expect(adapterEnv({ NODE_ENV: "development" })).toBe("development"); + expect(isDeployedEnv({ ADAPTER_ENV: "staging" })).toBe(true); + expect(isDeployedEnv({ ADAPTER_ENV: "development", NODE_ENV: "production" })).toBe(false); + }); + + it("trata ADAPTER_ENV inválido como ambiente implantado e aponta o erro", () => { + const env = { ADAPTER_ENV: "homolog", NODE_ENV: "production" }; + expect(isDeployedEnv(env)).toBe(true); + expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_ENV"); + }); + + it("namespeia recursos por aplicativo e ambiente", () => { + expect(resourceNamespace({ ADAPTER_ENV: "staging" })).toBe("adaptador-planilhas:staging"); + expect(resourceNamespace({ ADAPTER_ENV: "production" })).toBe("adaptador-planilhas:production"); + expect(resourceNamespace({ ADAPTER_ENV: "staging", ADAPTER_NAMESPACE: "outro-app" })).toBe("outro-app:staging"); + }); + + it("não deixa homologação e produção compartilharem namespace", () => { + expect(resourceNamespace({ ADAPTER_ENV: "staging" })).not.toBe(resourceNamespace({ ADAPTER_ENV: "production" })); + }); +}); + +describe("configuração de homologação", () => { + it("aceita o conjunto completo", () => { + expect(collectConfigIssues(stagingBase)).toEqual([]); + expect(() => assertDeployedEnv(stagingBase)).not.toThrow(); + }); + + it("recusa dev auth, inline e bucket público", () => { + const names = collectConfigIssues({ + ...stagingBase, + ADAPTER_DEV_AUTH: "1", + ADAPTER_JOB_MODE: "inline", + SUPABASE_STORAGE_BUCKET: "public", + }).map((issue) => issue.variable); + expect(names).toContain("ADAPTER_DEV_AUTH"); + expect(names).toContain("ADAPTER_JOB_MODE"); + expect(names).toContain("SUPABASE_STORAGE_BUCKET"); + }); + + it("exige decisão explícita sobre o shim de identidade", () => { + const env = { ...stagingBase } as Record; + delete env.ADAPTER_APPLY_IDENTITY_SHIM; + expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_APPLY_IDENTITY_SHIM"); + }); + + it("exige um caminho de login em homologação", () => { + const env = { ...stagingBase, ADAPTER_STAGING_AUTH: "0" }; + expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_STAGING_AUTH"); + expect(collectConfigIssues({ ...env, ELO_PRO_LOGIN_URL: "https://login.elo.example" })).toEqual([]); + }); + + it("não deixa o login próprio de homologação ir a produção", () => { + const env = { + ...stagingBase, + ADAPTER_ENV: "production", + SENTRY_DSN: "https://k@sentry.io/1", + ELO_PRO_LOGIN_URL: "https://login.elo.example", + }; + expect(collectConfigIssues(env).map((issue) => issue.variable)).toContain("ADAPTER_STAGING_AUTH"); + }); + + it("exige Sentry e login central em produção, mas não bloqueia homologação por isso", () => { + const production = { ...stagingBase, ADAPTER_ENV: "production", ADAPTER_STAGING_AUTH: "0" }; + const names = collectConfigIssues(production).map((issue) => issue.variable); + expect(names).toContain("SENTRY_DSN"); + expect(names).toContain("ELO_PRO_LOGIN_URL"); + expect(collectConfigIssues({ ...stagingBase, SENTRY_DSN: undefined })).toEqual([]); + }); + + it("não valida nada em development", () => { + expect(collectConfigIssues({ NODE_ENV: "development" })).toEqual([]); + expect(() => assertDeployedEnv({ NODE_ENV: "development" })).not.toThrow(); + }); + + it("recusa staging sem nenhuma variável e lista o que falta", () => { + expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging" })).toThrow(/DATABASE_URL/); + expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging" })).toThrow(/REDIS_URL/); + }); + + it("login próprio de homologação só liga com Supabase configurado", () => { + expect(stagingAuthEnabled(stagingBase)).toBe(true); + expect(stagingAuthEnabled({ ...stagingBase, NEXT_PUBLIC_SUPABASE_URL: "" })).toBe(false); + expect(stagingAuthEnabled({ ...stagingBase, ADAPTER_ENV: "production" })).toBe(false); + expect(stagingAuthEnabled({ ...stagingBase, ADAPTER_ENV: "development" })).toBe(false); + }); +}); + +describe("banco e migrations", () => { + it("nunca cai em PGlite fora de development", () => { + expect(() => requireDatabaseUrl({ ADAPTER_ENV: "staging" })).toThrow(/DATABASE_URL/); + expect(() => requireDatabaseUrl({ ADAPTER_ENV: "production" })).toThrow(/DATABASE_URL/); + expect(requireDatabaseUrl({ NODE_ENV: "development" })).toBeNull(); + expect(requireDatabaseUrl({ ADAPTER_ENV: "staging", DATABASE_URL: "postgres://h/d" })).toBe("postgres://h/d"); + }); + + it("o shim de identidade é decisão explícita em ambiente implantado", () => { + expect(identityShimEnabled({ NODE_ENV: "development" })).toBe(true); + expect(identityShimEnabled({ ADAPTER_ENV: "staging" })).toBe(false); + expect(identityShimEnabled({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toBe(true); + expect(identityShimEnabled({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "0" })).toBe(false); + expect(identityShimEnabled({ NODE_ENV: "development", ADAPTER_APPLY_IDENTITY_SHIM: "0" })).toBe(false); + }); + + it("reconhece qual migration é o shim", () => { + expect(isShimMigration("0001_platform_shim.sql")).toBe(true); + expect(isShimMigration("0002_spreadsheet_adapter.sql")).toBe(false); + expect(isShimMigration("0003_rls.sql")).toBe(false); + }); + + it("inline é recusado em ambiente implantado mesmo se a variável pedir", () => { + expect(jobRunsInline({ ADAPTER_ENV: "staging", ADAPTER_JOB_MODE: "inline" })).toBe(false); + expect(jobRunsInline({ ADAPTER_ENV: "production", ADAPTER_JOB_MODE: "inline" })).toBe(false); + expect(jobRunsInline({ NODE_ENV: "development", ADAPTER_JOB_MODE: "inline" })).toBe(true); + expect(jobRunsInline({ NODE_ENV: "development", ADAPTER_JOB_MODE: "worker" })).toBe(false); + }); +}); + +describe("resolução de workspace no servidor", () => { + it("sem associação não há workspace", () => { + expect(resolveWorkspace([], ALPHA)).toEqual({ kind: "none" }); + }); + + it("ignora a dica que não corresponde a associação real", () => { + const result = resolveWorkspace([membership(BETA, "Beta")], ALPHA); + expect(result).toEqual({ kind: "resolved", workspaceId: BETA }); + }); + + it("com várias associações e dica inválida, quem escolhe é a pessoa", () => { + const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], ALPHA.replace(/1/g, "9")); + expect(result.kind).toBe("choice"); + }); + + it("respeita a dica quando ela é uma associação real", () => { + const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], BETA); + expect(result).toEqual({ kind: "resolved", workspaceId: BETA }); + }); + + it("não aceita valor fora de UUID como dica", () => { + const result = resolveWorkspace([membership(ALPHA, "Alpha"), membership(BETA, "Beta")], "' or 1=1 --"); + expect(result.kind).toBe("choice"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index eb255b0..2e53a30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ esbuild: { jsx: "automatic" }, test: { environment: "node", + // tests/services exige Postgres e Redis reais: roda em vitest.services.config.ts + // para que `npm test` passe em qualquer máquina sem reportar casos ignorados. + exclude: ["node_modules/**", "dist/**", ".next/**", "tests/services/**"], setupFiles: ["./tests/setup.ts"], testTimeout: 30000, hookTimeout: 30000, diff --git a/vitest.services.config.ts b/vitest.services.config.ts new file mode 100644 index 0000000..b9e8380 --- /dev/null +++ b/vitest.services.config.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Suíte que exige Postgres e Redis reais. Fica fora de `npm test` de propósito: + * a suíte padrão roda em qualquer máquina, esta roda no CI com serviços. + * Sem `ADAPTER_TEST_DATABASE_URL` e `ADAPTER_TEST_REDIS_URL` os casos são + * marcados como ignorados, nunca como aprovados. + */ +export default defineConfig({ + esbuild: { jsx: "automatic" }, + test: { + environment: "node", + include: ["tests/services/**/*.test.ts"], + setupFiles: ["./tests/setup.ts"], + testTimeout: 120000, + hookTimeout: 120000, + fileParallelism: false, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "server-only": path.resolve(__dirname, "tests/helpers/server-only.ts"), + }, + }, +});