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,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<string, string>();
|
||||
|
||||
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<T = unknown>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -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<string, string> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof makeRuntimeDeps>;
|
||||
|
||||
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<string, string>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof prepare>>;
|
||||
|
||||
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<string> {
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>): 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<string, string | undefined>; 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user