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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user