diff --git a/tests/helpers/rls-catalog.ts b/tests/helpers/rls-catalog.ts new file mode 100644 index 0000000..71f573f --- /dev/null +++ b/tests/helpers/rls-catalog.ts @@ -0,0 +1,35 @@ +import type { Query } from "@/infra/db/types"; + +/** Tabelas que as migrations colocam sob RLS + FORCE RLS (migrations/0003_rls.sql). */ +export const RLS_TABLES = [ + "audit_log", + "profiles", + "spreadsheet_adapter_settings", + "spreadsheet_job_mappings", + "spreadsheet_job_sources", + "spreadsheet_jobs", + "spreadsheet_models", + "workspace_members", + "workspace_module_flags", + "workspaces", +] as const; + +export type RlsRow = { relname: string; relkind: string; relrowsecurity: boolean; relforcerowsecurity: boolean }; + +/** + * Só tabelas comuns e particionadas (`relkind` r/p) do schema public. Sem esse + * filtro, `like 'spreadsheet%'` também traz índices (`*_pkey`, relkind `i`), + * que nunca têm RLS e fazem a verificação falhar por engano. + */ +export async function readRlsCatalog(query: Query): Promise { + return query.query( + `select c.relname, c.relkind::text as relkind, c.relrowsecurity, c.relforcerowsecurity + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p') + and (c.relname like 'spreadsheet%' or c.relname = any($1::text[])) + order by c.relname`, + [[...RLS_TABLES]], + ); +} diff --git a/tests/integration/rls.test.ts b/tests/integration/rls.test.ts index 29f0f52..f319439 100644 --- a/tests/integration/rls.test.ts +++ b/tests/integration/rls.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createModel } from "@/server/spreadsheet-adapter/service"; import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world"; +import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog"; describe("RLS e permissões", () => { it("impede leitura e escrita entre workspaces", async () => { @@ -71,4 +72,16 @@ describe("RLS e permissões", () => { expect(rows.every((row) => row.action !== "tamper")).toBe(true); await db.close(); }); + + it("verifica RLS e FORCE RLS só em tabelas, nunca em índices", async () => { + const db = await createReadyDb(); + const rows = await db.withService(readRlsCatalog); + expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]); + expect(rows.some((row) => row.relname.endsWith("_pkey"))).toBe(false); + for (const row of rows) { + expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true); + expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true); + } + await db.close(); + }); }); diff --git a/tests/services/postgres.test.ts b/tests/services/postgres.test.ts index 4f5d0f5..1994f04 100644 --- a/tests/services/postgres.test.ts +++ b/tests/services/postgres.test.ts @@ -2,6 +2,7 @@ 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"; +import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog"; /** * RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa @@ -68,14 +69,10 @@ describe.skipIf(!enabled)("PostgreSQL real", () => { }); 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); + const rows = await db.withService(readRlsCatalog); + expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]); for (const row of rows) { + expect(["r", "p"], `${row.relname} não é tabela`).toContain(row.relkind); expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true); expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true); } @@ -111,16 +108,34 @@ describe.skipIf(!enabled)("PostgreSQL real", () => { }); it("não deixa o papel da aplicação alterar auditoria", async () => { + // workspace_id é uuid e resource_id é text: cada um tem o seu parâmetro, + // senão o Postgres recusa com "inconsistent types deduced for parameter". 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], + values ($1::uuid, $2::uuid, 'test.event', 'workspace', $3::text, 'success')`, + [ALPHA, ANA, ALPHA], ), ); - await expect( - db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)), - ).rejects.toThrow(); + + // A recusa tem de vir da falta de privilégio (42501), não de sintaxe ou tipo. + for (const statement of [ + `update public.audit_log set result = 'error' where workspace_id = $1::uuid`, + `delete from public.audit_log where workspace_id = $1::uuid`, + ]) { + await expect( + db.withUser(ANA, (query) => query.query(statement, [ALPHA])), + ).rejects.toMatchObject({ code: "42501", message: expect.stringMatching(/permission denied/i) }); + } + + const rows = await db.withService((query) => + query.query<{ result: string }>( + `select result from public.audit_log where workspace_id = $1::uuid and action = 'test.event'`, + [ALPHA], + ), + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((row) => row.result === "success")).toBe(true); }); it("pula o shim quando a decisão é não aplicar", async () => {