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>
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
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");
|
|
});
|
|
});
|