Compare commits

...
4 Commits
Author SHA1 Message Date
camilafagundesandCursor 8cfeeb0464 fix(db): desacopla configuracao administrativa das migrations
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 01:14:00 -03:00
camilafagundesandCursor 93f280621b fix(db): restringe privilegios e torna migrations atomicas
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 00:28:43 -03:00
camilafagundesandCursor af559ca455 fix(db): trust verified Supabase root CA for deployed TLS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 19:42:46 -03:00
camilafagundesandCursor 60f15a3576 fix(db): remove invalid postgres startup parameter
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:20:06 -03:00
15 changed files with 614 additions and 35 deletions

No files matched your search

+47 -2
View File
@@ -93,12 +93,15 @@ returns boolean
language sql language sql
stable stable
security definer security definer
set search_path = public set search_path = pg_catalog
as $$ as $$
select exists ( select exists (
select 1 from public.spreadsheet_jobs j select 1
from public.spreadsheet_jobs j
join public.spreadsheet_models m on m.id = j.model_id
where j.model_id = model where j.model_id = model
and j.status in ('validating', 'ready', 'queued', 'processing') and j.status in ('validating', 'ready', 'queued', 'processing')
and public.is_member(m.workspace_id)
) )
$$; $$;
@@ -208,14 +211,17 @@ create policy models_update on public.spreadsheet_models
with check (public.can_manage_adapter(workspace_id)); with check (public.can_manage_adapter(workspace_id));
drop policy if exists jobs_all on public.spreadsheet_jobs; drop policy if exists jobs_all on public.spreadsheet_jobs;
drop policy if exists jobs_select on public.spreadsheet_jobs;
create policy jobs_select on public.spreadsheet_jobs create policy jobs_select on public.spreadsheet_jobs
for select to app_user for select to app_user
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id)); using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
drop policy if exists jobs_insert on public.spreadsheet_jobs;
create policy jobs_insert on public.spreadsheet_jobs create policy jobs_insert on public.spreadsheet_jobs
for insert to app_user for insert to app_user
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id)); with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
drop policy if exists jobs_update on public.spreadsheet_jobs;
create policy jobs_update on public.spreadsheet_jobs create policy jobs_update on public.spreadsheet_jobs
for update to app_user for update to app_user
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id)) using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
@@ -229,6 +235,7 @@ create policy sources_select on public.spreadsheet_job_sources
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id) where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
)); ));
drop policy if exists sources_insert on public.spreadsheet_job_sources;
create policy sources_insert on public.spreadsheet_job_sources create policy sources_insert on public.spreadsheet_job_sources
for insert to app_user for insert to app_user
with check (exists ( with check (exists (
@@ -236,6 +243,7 @@ create policy sources_insert on public.spreadsheet_job_sources
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id) where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
)); ));
drop policy if exists sources_update on public.spreadsheet_job_sources;
create policy sources_update on public.spreadsheet_job_sources create policy sources_update on public.spreadsheet_job_sources
for update to app_user for update to app_user
using (exists ( using (exists (
@@ -247,6 +255,7 @@ create policy sources_update on public.spreadsheet_job_sources
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id) where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
)); ));
drop policy if exists sources_delete on public.spreadsheet_job_sources;
create policy sources_delete on public.spreadsheet_job_sources create policy sources_delete on public.spreadsheet_job_sources
for delete to app_user for delete to app_user
using (exists ( using (exists (
@@ -263,6 +272,7 @@ create policy mappings_select on public.spreadsheet_job_mappings
where s.id = job_source_id and j.user_id = public.auth_uid() where s.id = job_source_id and j.user_id = public.auth_uid()
)); ));
drop policy if exists mappings_insert on public.spreadsheet_job_mappings;
create policy mappings_insert on public.spreadsheet_job_mappings create policy mappings_insert on public.spreadsheet_job_mappings
for insert to app_user for insert to app_user
with check (exists ( with check (exists (
@@ -271,6 +281,7 @@ create policy mappings_insert on public.spreadsheet_job_mappings
where s.id = job_source_id and j.user_id = public.auth_uid() where s.id = job_source_id and j.user_id = public.auth_uid()
)); ));
drop policy if exists mappings_delete on public.spreadsheet_job_mappings;
create policy mappings_delete on public.spreadsheet_job_mappings create policy mappings_delete on public.spreadsheet_job_mappings
for delete to app_user for delete to app_user
using (exists ( using (exists (
@@ -282,3 +293,37 @@ create policy mappings_delete on public.spreadsheet_job_mappings
grant usage on schema public to app_user; grant usage on schema public to app_user;
grant select, insert, update, delete on all tables in schema public to app_user; grant select, insert, update, delete on all tables in schema public to app_user;
grant usage, select on all sequences in schema public to app_user; grant usage, select on all sequences in schema public to app_user;
-- schema_migrations não tem RLS. A escrita fica só com o dono que aplica as migrations.
revoke insert, update, delete, truncate, references, trigger on table public.schema_migrations from public, app_user;
do $priv$
declare
versao int := current_setting('server_version_num')::int;
escrita text := 'insert, update, delete, truncate, references, trigger';
papel text;
begin
if versao >= 170000 then
escrita := escrita || ', maintain';
execute 'revoke maintain on table public.schema_migrations from public, app_user';
end if;
foreach papel in array array['anon', 'authenticated', 'service_role', 'adaptador_web', 'adaptador_worker']
loop
if exists (select 1 from pg_catalog.pg_roles where rolname = papel) then
execute format(
'revoke %s on table public.profiles, public.workspaces, public.workspace_members, public.workspace_module_flags, public.audit_log, public.spreadsheet_adapter_settings, public.spreadsheet_models, public.spreadsheet_jobs, public.spreadsheet_job_sources, public.spreadsheet_job_mappings, public.schema_migrations from %I',
escrita,
papel
);
if papel in ('anon', 'authenticated', 'service_role') then
execute format('revoke select on table public.schema_migrations from %I', papel);
execute format(
'alter default privileges in schema public revoke %s on tables from %I',
escrita,
papel
);
end if;
end if;
end loop;
end
$priv$;
+33 -8
View File
@@ -1,20 +1,44 @@
import { identityShimEnabled } from "../src/config/env"; import { adapterEnv, identityShimEnabled } from "../src/config/env";
import { migrate, pendingMigrations } from "../src/infra/db/migrate"; import { exitOnInvalidMigrationConfig } from "../src/config/fail-fast";
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap"; import {
assertManifestMatches,
listMigrationFiles,
migrate,
pendingMigrations,
readMigrationLedger,
} from "../src/infra/db/migrate";
import { bootstrapScript, describeTarget, runScript, runWithDb } from "./_bootstrap";
/** /**
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline * Três modos, separados de propósito:
* antes do passo manual de migração, para que ninguém descubra o que vai rodar * - `--plan` lista o manifesto no disco e não abre banco.
* só depois de ter rodado. * - `--dry-run` lê `schema_migrations` e não aplica SQL.
* - sem flag, aplica o lote pendente numa transação.
*/ */
const plan = process.argv.includes("--plan");
const dryRun = process.argv.includes("--dry-run"); const dryRun = process.argv.includes("--dry-run");
bootstrapScript("migrate"); if (plan) {
runScript("migrate", async () => {
const files = await listMigrationFiles();
assertManifestMatches(files);
console.log(`plano estatico offline: ${files.join(", ")}`);
});
} else {
if (adapterEnv() === "staging") exitOnInvalidMigrationConfig(process.env);
else if (adapterEnv() === "production") {
console.error(
"[adaptador-planilhas] Configuração administrativa recusada. ADAPTER_ENV: migrate administrativo de homologação exige staging",
);
process.exit(1);
} else bootstrapScript("migrate");
runWithDb("migrate", "admin", async (db) => { runWithDb("migrate", "admin", async (db) => {
const ledger = await readMigrationLedger(db);
const pending = await pendingMigrations(db); const pending = await pendingMigrations(db);
console.log(`alvo: ${describeTarget()}`); console.log(`alvo: ${adapterEnv() === "staging" ? "postgres administrativo" : describeTarget()}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`); console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
if (ledger.state === "ausente") console.log("historico: ausente; schema_migrations nao foi criada");
if (pending.length === 0) { if (pending.length === 0) {
console.log("migrations: nada pendente"); console.log("migrations: nada pendente");
return; return;
@@ -26,3 +50,4 @@ runWithDb("migrate", "admin", async (db) => {
const ran = await migrate(db); const ran = await migrate(db);
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente"); console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
}); });
}
+41
View File
@@ -138,6 +138,47 @@ export function formatConfigIssues(issues: EnvIssue[]): string {
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; "); return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
} }
const MIGRATION_REF_PATTERN = /^[a-z0-9]{20}$/;
const MIGRATION_POOLER_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/;
/**
* Configuração do `npm run migrate` em homologação. Não inclui web, Redis,
* Storage, Auth nem worker: esses papéis ainda não existem quando a migration
* administrativa cria o schema.
*/
export function collectMigrationAdminIssues(env: EnvMap = process.env): EnvIssue[] {
const issues: EnvIssue[] = [];
if (adapterEnv(env) !== "staging") {
issues.push({ variable: "ADAPTER_ENV", message: "migrate administrativo de homologação exige staging" });
}
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
if (!MIGRATION_REF_PATTERN.test(ref)) {
issues.push({ variable: "ADAPTER_DB_PROJECT_REF", message: "Reference ID ausente ou fora do formato" });
}
const pooler = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? "";
if (!MIGRATION_POOLER_PATTERN.test(pooler)) {
issues.push({ variable: "ADAPTER_DB_POOLER_HOST", message: "host do Session Pooler ausente ou recusado" });
}
if (!env.ADAPTER_MIGRATION_DATABASE_URL?.trim()) {
issues.push({ variable: "ADAPTER_MIGRATION_DATABASE_URL", message: "obrigatória para o migrate administrativo" });
}
const shim = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim();
if (shim !== "0" && shim !== "1") {
issues.push({
variable: "ADAPTER_APPLY_IDENTITY_SHIM",
message: "decisão explícita obrigatória. 1 aplica o shim, 0 usa o schema existente",
});
}
return issues;
}
/** Recusa a configuração administrativa incompleta. Não abre conexão. */
export function assertMigrationAdminEnv(env: EnvMap = process.env): void {
const issues = collectMigrationAdminIssues(env);
if (issues.length === 0) return;
throw new Error(`Configuração administrativa recusada. ${formatConfigIssues(issues)}`);
}
function skipAssertion(env: EnvMap): boolean { function skipAssertion(env: EnvMap): boolean {
if (env.NEXT_PHASE === "phase-production-build") return true; if (env.NEXT_PHASE === "phase-production-build") return true;
if (env.npm_lifecycle_event === "build") return true; if (env.npm_lifecycle_event === "build") return true;
+17 -1
View File
@@ -1,5 +1,5 @@
import { buildPostgresOptions } from "@/infra/db/connection-policy"; import { buildPostgresOptions } from "@/infra/db/connection-policy";
import { assertDeployedEnv, isDeployedEnv, type EnvMap } from "./env"; import { assertDeployedEnv, assertMigrationAdminEnv, isDeployedEnv, type EnvMap } from "./env";
/** /**
* Encerra o processo com código diferente de zero quando a configuração é * Encerra o processo com código diferente de zero quando a configuração é
@@ -21,3 +21,19 @@ export function exitOnInvalidConfig(env: EnvMap = process.env): void {
process.exit(1); process.exit(1);
} }
} }
/**
* Barreira do `npm run migrate` em staging. Exige a URL administrativa e o
* shim explícito. A URL web, o Redis e o Storage continuam fora desta função.
*/
export function exitOnInvalidMigrationConfig(env: EnvMap = process.env): void {
try {
assertMigrationAdminEnv(env);
const adminUrl = env.ADAPTER_MIGRATION_DATABASE_URL?.trim();
if (adminUrl) buildPostgresOptions(adminUrl, "admin", env);
} catch (error) {
const message = error instanceof Error ? error.message : "Configuração recusada.";
console.error(`[adaptador-planilhas] ${message}`);
process.exit(1);
}
}
+16 -7
View File
@@ -1,5 +1,6 @@
import type { EnvMap } from "@/config/env"; import type { EnvMap } from "@/config/env";
import { isDeployedEnv } from "@/config/env"; import { isDeployedEnv } from "@/config/env";
import { SUPABASE_ROOT_CA_2021 } from "./supabase-root-ca";
/** Projeto autorizado a receber migrations. Não é segredo. */ /** Projeto autorizado a receber migrations. Não é segredo. */
export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml"; export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml";
@@ -169,11 +170,22 @@ export type PostgresClientOptions = {
connect_timeout: number; connect_timeout: number;
idle_timeout: number; idle_timeout: number;
max_lifetime: number; max_lifetime: number;
ssl?: { rejectUnauthorized: true }; ssl?: { rejectUnauthorized: true; ca: string };
connection?: { family: 4 };
}; };
/** Opções do driver. Em ambiente implantado o certificado é verificado sempre. */ /**
* Opções do driver. Em ambiente implantado o certificado é verificado sempre.
*
* `connection` no postgres.js é um parâmetro de startup, não uma opção do
* socket. Enviar `family=4` faz o PostgreSQL recusar a sessão com 42704
* antes da primeira consulta. O IPv4 continua no host do Session Pooler.
*
* O pooler apresenta uma cadeia emitida pela raiz pública Supabase Root 2021
* CA. Essa raiz não está na confiança padrão do Node, e por isso a verificação
* falha com SELF_SIGNED_CERT_IN_CHAIN. `ca` fixa somente essa raiz neste
* socket. `rejectUnauthorized` permanece verdadeiro, e o driver continua
* conferindo o hostname. A confiança padrão dos outros processos não muda.
*/
export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions { export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions {
const deployed = isDeployedEnv(env); const deployed = isDeployedEnv(env);
if (deployed) assertDeployedUrl(connectionString, purpose, env); if (deployed) assertDeployedUrl(connectionString, purpose, env);
@@ -185,10 +197,7 @@ export function buildPostgresOptions(connectionString: string, purpose: DbPurpos
idle_timeout: 20, idle_timeout: 20,
max_lifetime: 60 * 30, max_lifetime: 60 * 30,
}; };
if (deployed) { if (deployed) options.ssl = { rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 };
options.ssl = { rejectUnauthorized: true };
options.connection = { family: 4 };
}
return options; return options;
} }
+26 -8
View File
@@ -16,6 +16,8 @@ import type { Db } from "./types";
*/ */
const MIGRATIONS_FOLDER = "migrations"; const MIGRATIONS_FOLDER = "migrations";
/** Trava só durante a transação do migrate. A tabela de controle permanece id e applied_at. */
const MIGRATION_LOCK = 872341001;
export type MigrateOptions = { export type MigrateOptions = {
/** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */ /** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */
@@ -51,27 +53,43 @@ export function assertManifestMatches(files: string[]): void {
export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> { export async function migrate(db: Db, options: MigrateOptions = {}): Promise<string[]> {
const applyShim = options.applyIdentityShim ?? identityShimEnabled(); const applyShim = options.applyIdentityShim ?? identityShimEnabled();
const files = await listMigrationFiles();
assertManifestMatches(files);
const sqlByFile = new Map<string, string>();
for (const file of files) {
sqlByFile.set(file, await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"));
}
await db.exec(` return db.withTransaction(async (tx) => {
await tx.exec(`select pg_catalog.pg_advisory_xact_lock(${MIGRATION_LOCK})`);
await tx.exec(`
create table if not exists public.schema_migrations ( create table if not exists public.schema_migrations (
id text primary key, id text primary key,
applied_at timestamptz not null default now() applied_at timestamptz not null default now()
); );
`); `);
const files = await listMigrationFiles(); const appliedRows = await tx.query<{ id: string }>("select id from public.schema_migrations");
assertManifestMatches(files);
const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"));
const applied = new Set(appliedRows.map((row) => row.id)); const applied = new Set(appliedRows.map((row) => row.id));
const ran: string[] = []; const ran: string[] = [];
for (const file of files) { for (const file of files) {
if (applied.has(file)) continue; if (applied.has(file)) continue;
if (isShimMigration(file) && !applyShim) continue; if (isShimMigration(file) && !applyShim) continue;
const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"); const sql = sqlByFile.get(file);
await db.exec(sql); if (!sql) throw new Error(`migration ausente: ${file}`);
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file])); await tx.exec(sql);
await tx.query("insert into public.schema_migrations (id) values ($1)", [file]);
ran.push(file); ran.push(file);
} }
return ran; return ran;
});
} }
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest"; export {
EXPECTED_MIGRATIONS,
SHIM_MIGRATIONS,
isShimMigration,
pendingMigrations,
readMigrationLedger,
MigrationLedgerError,
} from "./migration-manifest";
export type { MigrationLedger } from "./migration-manifest";
+38 -4
View File
@@ -29,12 +29,46 @@ export function isShimMigration(file: string): boolean {
export type PendingOptions = { applyIdentityShim?: boolean }; export type PendingOptions = { applyIdentityShim?: boolean };
export type MigrationLedger = { state: "ausente" } | { state: "presente"; ids: string[] };
/** A leitura do histórico falhou. Não é o mesmo que tabela ausente. */
export class MigrationLedgerError extends Error {
readonly code = "historico_indisponivel";
constructor() {
super("historico_indisponivel");
this.name = "MigrationLedgerError";
}
}
/**
* Lê o histórico sem criá-lo. Tabela ausente é um estado explícito. Falha de
* permissão, conexão ou consulta interrompe: não vira lista vazia.
*/
export async function readMigrationLedger(db: Db): Promise<MigrationLedger> {
let presence: { name: string | null }[];
try {
presence = await db.withService((query) =>
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
);
} catch {
throw new MigrationLedgerError();
}
if (!presence[0]?.name) return { state: "ausente" };
try {
const rows = await db.withService((query) =>
query.query<{ id: string }>("select id from public.schema_migrations order by id"),
);
return { state: "presente", ids: rows.map((row) => row.id) };
} catch {
throw new MigrationLedgerError();
}
}
/** Migrations esperadas que ainda não constam em `schema_migrations`. */ /** Migrations esperadas que ainda não constam em `schema_migrations`. */
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> { export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
const applyShim = options.applyIdentityShim ?? identityShimEnabled(); const applyShim = options.applyIdentityShim ?? identityShimEnabled();
const appliedRows = await db const ledger = await readMigrationLedger(db);
.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations")) const applied = new Set(ledger.state === "presente" ? ledger.ids : []);
.catch(() => [] as { id: string }[]);
const applied = new Set(appliedRows.map((row) => row.id));
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file))); return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
} }
+23 -1
View File
@@ -1,5 +1,5 @@
import { PGlite } from "@electric-sql/pglite"; import { PGlite } from "@electric-sql/pglite";
import { createMutex, type Db, type Query, type Row, assertUuid } from "./types"; import { createMutex, type Db, type Query, type Row, type Tx, assertUuid } from "./types";
type PgClient = { type PgClient = {
exec(sql: string): Promise<unknown>; exec(sql: string): Promise<unknown>;
@@ -44,6 +44,28 @@ export function wrapPglite(client: PgClient): Db {
close() { close() {
return client.close(); return client.close();
}, },
withTransaction(fn) {
return exclusive(async () => {
await client.exec("begin");
const tx: Tx = {
exec(sql) {
return client.exec(sql).then(() => undefined);
},
async query<T = Row>(sql: string, params: unknown[] = []) {
const result = await client.query(sql, params);
return result.rows as T[];
},
};
try {
const value = await fn(tx);
await client.exec("commit");
return value;
} catch (error) {
await client.exec("rollback");
throw error;
}
});
},
}; };
} }
+15 -1
View File
@@ -1,6 +1,6 @@
import postgres from "postgres"; import postgres from "postgres";
import type { PostgresClientOptions } from "./connection-policy"; import type { PostgresClientOptions } from "./connection-policy";
import { assertUuid, type Db, type Query, type Row } from "./types"; import { assertUuid, type Db, type Query, type Row, type Tx } from "./types";
export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db { export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
const sql = postgres(connectionString, options ?? { max: 8, prepare: false }); const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
@@ -33,5 +33,19 @@ export function createPostgresDb(connectionString: string, options?: PostgresCli
async close() { async close() {
await sql.end(); await sql.end();
}, },
async withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
const result = await sql.begin(async (tx) => {
const adapter: Tx = {
async exec(text: string) {
await tx.unsafe(text);
},
async query<R = Row>(text: string, params: unknown[] = []) {
return tx.unsafe(text, params as never[]) as unknown as R[];
},
};
return fn(adapter);
});
return result as T;
},
}; };
} }
+28
View File
@@ -0,0 +1,28 @@
/**
* CA raiz pública do Supabase, publicada como prod-ca-2021.
* Não contém chave privada. A impressão SHA-256 é conferida nos testes.
*/
export const SUPABASE_ROOT_CA_2021 = `-----BEGIN CERTIFICATE-----
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
o/bKiIz+Fq8=
-----END CERTIFICATE-----
`;
+8 -1
View File
@@ -113,6 +113,13 @@ function postgresErrorCode(error: unknown): string | null {
return null; return null;
} }
/** Código do driver, sem a mensagem. A mensagem pode conter usuário, host ou senha. */
function unavailableIdentityCode(error: unknown): string {
const code = postgresErrorCode(error);
if (code && /^[A-Z0-9_]{2,48}$/.test(code)) return `identidade_indisponivel:${code}`;
return "identidade_indisponivel";
}
async function readRole(db: Db): Promise<FactRow> { async function readRole(db: Db): Promise<FactRow> {
try { try {
const rows = await db.withService((query) => const rows = await db.withService((query) =>
@@ -132,7 +139,7 @@ async function readRole(db: Db): Promise<FactRow> {
return facts; return facts;
} catch (error) { } catch (error) {
if (error instanceof DatabaseGuardError) throw error; if (error instanceof DatabaseGuardError) throw error;
throw new DatabaseGuardError("identidade_indisponivel"); throw new DatabaseGuardError(unavailableIdentityCode(error));
} }
} }
+7
View File
@@ -4,10 +4,17 @@ export interface Query {
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>; query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
} }
export interface Tx {
exec(sql: string): Promise<void>;
query<T = Row>(sql: string, params?: unknown[]): Promise<T[]>;
}
export interface Db { export interface Db {
exec(sql: string): Promise<void>; exec(sql: string): Promise<void>;
withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>; withUser<T>(userId: string, fn: (query: Query) => Promise<T>): Promise<T>;
withService<T>(fn: (query: Query) => Promise<T>): Promise<T>; withService<T>(fn: (query: Query) => Promise<T>): Promise<T>;
/** Uma conexão, uma transação. O callback inteiro confirma ou reverte junto. */
withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
close(): Promise<void>; close(): Promise<void>;
} }
+34 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createPostgresDb } from "@/infra/db/postgres"; import { createPostgresDb } from "@/infra/db/postgres";
import { migrate, pendingMigrations } from "@/infra/db/migrate"; import { migrate, pendingMigrations, readMigrationLedger, MigrationLedgerError } from "@/infra/db/migrate";
import type { Db } from "@/infra/db/types"; import type { Db } from "@/infra/db/types";
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog"; import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
@@ -12,6 +12,13 @@ import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
const url = process.env.ADAPTER_TEST_DATABASE_URL; const url = process.env.ADAPTER_TEST_DATABASE_URL;
const enabled = Boolean(url); const enabled = Boolean(url);
function roleUrl(base: string, role: string): string {
const parsed = new URL(base);
parsed.username = role;
parsed.password = "";
return parsed.toString();
}
const ALPHA = "11111111-1111-4111-8111-111111111111"; const ALPHA = "11111111-1111-4111-8111-111111111111";
const BETA = "22222222-2222-4222-8222-222222222222"; const BETA = "22222222-2222-4222-8222-222222222222";
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
@@ -161,6 +168,32 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
expect(queued[0]?.current_user).toBe("app_user"); expect(queued[0]?.current_user).toBe("app_user");
}); });
it("separa histórico ausente, íntegro e ilegível", async () => {
await db.exec("drop table public.schema_migrations");
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
const aindaAusente = await db.withService((query) =>
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
);
expect(aindaAusente[0]?.name ?? null).toBeNull();
await migrate(db, { applyIdentityShim: true });
const presente = await readMigrationLedger(db);
expect(presente.state).toBe("presente");
if (presente.state === "presente") expect(presente.ids).toHaveLength(5);
await db.exec(`
do $$ begin
create role historico_leitor nologin nosuperuser nobypassrls;
exception when duplicate_object then null;
end $$;
grant usage on schema public to historico_leitor;
alter role historico_leitor login;
`);
const leitor = createPostgresDb(roleUrl(url as string, "historico_leitor"));
await expect(readMigrationLedger(leitor)).rejects.toBeInstanceOf(MigrationLedgerError);
await leitor.close();
await db.exec("revoke all on schema public from historico_leitor; drop role historico_leitor");
});
it("pula o shim quando a decisão é não aplicar", async () => { it("pula o shim quando a decisão é não aplicar", async () => {
const pending = await pendingMigrations(db, { applyIdentityShim: false }); const pending = await pendingMigrations(db, { applyIdentityShim: false });
expect(pending).not.toContain("0001_platform_shim.sql"); expect(pending).not.toContain("0001_platform_shim.sql");
+111 -1
View File
@@ -1,3 +1,8 @@
import { X509Certificate } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import tls from "node:tls";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
AUTHORIZED_HML_PROJECT, AUTHORIZED_HML_PROJECT,
@@ -5,6 +10,7 @@ import {
DatabaseGuardError, DatabaseGuardError,
assertDeployedUrl, assertDeployedUrl,
assertLocalDatabaseUrl, assertLocalDatabaseUrl,
buildPostgresOptions,
expectedPoolerHost, expectedPoolerHost,
resolvePoolMax, resolvePoolMax,
scrubDbError, scrubDbError,
@@ -12,7 +18,9 @@ import {
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity"; import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity";
import { evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
import { assertAdminTarget, evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard";
import type { Db } from "@/infra/db/types";
const REF = "abcdefghijabcdefghij"; const REF = "abcdefghijabcdefghij";
const REF_COM_DIGITO = "abcd1234abcd1234wxyz"; const REF_COM_DIGITO = "abcd1234abcd1234wxyz";
@@ -165,6 +173,73 @@ describe("barreira do banco de homologação", () => {
expect(resolvePoolMax("web", env)).toBe(2); expect(resolvePoolMax("web", env)).toBe(2);
expect(resolvePoolMax("worker", env)).toBe(2); expect(resolvePoolMax("worker", env)).toBe(2);
expect(resolvePoolMax("admin", env)).toBe(1); expect(resolvePoolMax("admin", env)).toBe(1);
const root = new X509Certificate(SUPABASE_ROOT_CA_2021);
expect(root.ca).toBe(true);
expect(root.fingerprint256).toBe("80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA");
expect(root.subject).toContain("CN=Supabase Root 2021 CA");
expect(SUPABASE_ROOT_CA_2021).not.toContain("PRIVATE KEY");
const deployed = buildPostgresOptions(adminUrl(), "admin", env);
const production = buildPostgresOptions(adminUrl(), "admin", { ...env, ADAPTER_ENV: "production" });
for (const options of [deployed, production]) {
expect(options.ssl).toEqual({ rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 });
expect(options).not.toHaveProperty("connection");
expect(JSON.stringify(options)).not.toContain("family");
expect(JSON.stringify(options)).not.toContain("rejectUnauthorized\":false");
}
const local = buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", {
ADAPTER_ENV: "development",
});
expect(local).not.toHaveProperty("connection");
expect(local.ssl).toBeUndefined();
});
it("a CA pública não libera outro destino nem aceita certificado divergente", async () => {
expect(() =>
buildPostgresOptions(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env),
).toThrow(/destino_incompativel/);
expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF })).toThrow(/init_nao_autorizada/);
const options = buildPostgresOptions(adminUrl(), "admin", env);
expect(options.ssl).not.toHaveProperty("checkServerIdentity");
expect(options.ssl).not.toHaveProperty("servername");
expect(options.ssl?.rejectUnauthorized).toBe(true);
const dir = mkdtempSync(path.join(tmpdir(), "ca-divergente-"));
const keyPath = path.join(dir, "key.pem");
const certPath = path.join(dir, "cert.pem");
try {
execFileSync(
"openssl",
["req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", keyPath, "-out", certPath, "-days", "1", "-nodes", "-subj", "/CN=divergente.example"],
{ stdio: "ignore" },
);
const server = tls.createServer({ key: readFileSync(keyPath), cert: readFileSync(certPath) }, (socket) => socket.end());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("porta_local_ausente");
try {
await new Promise<void>((resolve, reject) => {
const socket = tls.connect({
host: "127.0.0.1",
port: address.port,
servername: "divergente.example",
rejectUnauthorized: true,
ca: SUPABASE_ROOT_CA_2021,
});
socket.once("secureConnect", () => {
socket.end();
reject(new Error("certificado_divergente_aceito"));
});
socket.once("error", (error: NodeJS.ErrnoException) => {
expect(error.code).toMatch(/SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/);
resolve();
});
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}); });
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => { it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
@@ -207,6 +282,41 @@ describe("barreira do banco de homologação", () => {
); );
}); });
it("classifica a falha de conexão pelo código do driver e omite a mensagem", async () => {
const db = {
exec: async () => undefined,
close: async () => undefined,
withUser: async () => {
throw new Error("nao");
},
withService: async () => {
throw Object.assign(new Error("postgres://postgres.ref:segredo@host/db family"), { code: "42704" });
},
} as Db;
await expect(assertAdminTarget(db, env)).rejects.toMatchObject({
code: "identidade_indisponivel:42704",
});
await expect(assertAdminTarget(db, env)).rejects.not.toThrow(/segredo/);
const semCodigo = {
...db,
withService: async () => {
throw new Error("postgres://postgres.ref:segredo@host/db");
},
} as Db;
await expect(assertAdminTarget(semCodigo, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
await expect(assertAdminTarget(semCodigo, env)).rejects.not.toThrow(/segredo/);
const codigoInseguro = {
...db,
withService: async () => {
throw Object.assign(new Error("falha"), { code: "postgres://segredo" });
},
} as Db;
await expect(assertAdminTarget(codigoInseguro, env)).rejects.toMatchObject({ code: "identidade_indisponivel" });
await expect(assertAdminTarget(codigoInseguro, env)).rejects.not.toThrow(/segredo/);
});
it("não deixa senha nem URL no erro visível", () => { it("não deixa senha nem URL no erro visível", () => {
const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres")); const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres"));
expect(scrubbed).not.toContain("segredo"); expect(scrubbed).not.toContain("segredo");
+170
View File
@@ -0,0 +1,170 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { assertDeployedEnv, assertMigrationAdminEnv, collectMigrationAdminIssues } from "@/config/env";
import { DatabaseGuardError, buildPostgresOptions } from "@/infra/db/connection-policy";
import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
import { evaluateProjectIdentity } from "@/infra/db/target-guard";
import { AUTHORIZED_HML_PROJECT } from "@/infra/db/connection-policy";
import {
MigrationLedgerError,
pendingMigrations,
readMigrationLedger,
} from "@/infra/db/migration-manifest";
import { createMemoryDb } from "@/infra/db/pglite";
import { migrate } from "@/infra/db/migrate";
import type { Db, Query } from "@/infra/db/types";
const REF = "abcd1234abcd1234wxyz";
const OUTRO = "zzzzzzzzzzzzzzzzzzz1";
const HOST = "aws-1-us-west-2.pooler.supabase.com";
function adminUrl(user = `postgres.${REF}`, database = "postgres", host = HOST, sslmode = "verify-full"): string {
return `postgres://${user}:segredo@${host}:5432/${database}?sslmode=${sslmode}`;
}
const adminEnv = {
ADAPTER_ENV: "staging",
ADAPTER_DB_PROJECT_REF: REF,
ADAPTER_DB_POOLER_HOST: HOST,
ADAPTER_MIGRATION_DATABASE_URL: adminUrl(),
ADAPTER_APPLY_IDENTITY_SHIM: "1",
};
function banco(query: Query["query"]): Db {
return {
exec: async () => undefined,
withUser: async (_userId, fn) => fn({ query }),
withService: async (fn) => fn({ query }),
withTransaction: async (fn) => fn({ exec: async () => undefined, query }),
close: async () => undefined,
};
}
describe("migrate administrativo", () => {
it("aceita staging só com a configuração administrativa", () => {
expect(collectMigrationAdminIssues(adminEnv)).toEqual([]);
expect(() => assertMigrationAdminEnv(adminEnv)).not.toThrow();
const options = buildPostgresOptions(adminEnv.ADAPTER_MIGRATION_DATABASE_URL, "admin", adminEnv);
expect(options.ssl?.rejectUnauthorized).toBe(true);
expect(options.ssl?.ca).toBe(SUPABASE_ROOT_CA_2021);
});
it("não exige web, Redis, Storage, login nem TTL", () => {
const env = { ...adminEnv };
expect(env).not.toHaveProperty("DATABASE_URL");
expect(env).not.toHaveProperty("REDIS_URL");
expect(env).not.toHaveProperty("SUPABASE_STORAGE_BUCKET");
expect(env).not.toHaveProperty("ADAPTER_STAGING_AUTH");
expect(env).not.toHaveProperty("SPREADSHEET_TEMP_TTL_SECONDS");
expect(() => assertMigrationAdminEnv(env)).not.toThrow();
});
it("recusa Reference ID ausente e divergente", () => {
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_PROJECT_REF: "" }).map((issue) => issue.variable)).toContain(
"ADAPTER_DB_PROJECT_REF",
);
expect(() => buildPostgresOptions(adminUrl(`postgres.${OUTRO}`), "admin", adminEnv)).toThrow(DatabaseGuardError);
expect(() =>
evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: OUTRO }, adminEnv),
).toThrow(/marcador_diverge/);
});
it("recusa host, banco, usuário e TLS fora do destino", () => {
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_POOLER_HOST: "db.exemplo.supabase.co" }).map((item) => item.variable)).toContain(
"ADAPTER_DB_POOLER_HOST",
);
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", "aws-9-us-west-2.pooler.supabase.com"), "admin", adminEnv)).toThrow(
/destino_incompativel/,
);
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "template1"), "admin", adminEnv)).toThrow(/destino_incompativel/);
expect(() => buildPostgresOptions(adminUrl(`adaptador_web.${REF}`), "admin", adminEnv)).toThrow(/usuario_url_diverge/);
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", HOST, "require"), "admin", adminEnv)).toThrow(/tls_inseguro/);
expect(() =>
buildPostgresOptions(
`postgres://postgres.${REF}:segredo@supabase.elopro.app:5432/postgres?sslmode=verify-full`,
"admin",
{ ...adminEnv, ADAPTER_DB_POOLER_HOST: "supabase.elopro.app" },
),
).toThrow(/host_elopro_preservado|pooler/);
});
it("recusa shim ausente e ambiente que não é staging", () => {
const semShim = { ...adminEnv } as Record<string, string | undefined>;
delete semShim.ADAPTER_APPLY_IDENTITY_SHIM;
expect(collectMigrationAdminIssues(semShim).map((issue) => issue.variable)).toContain("ADAPTER_APPLY_IDENTITY_SHIM");
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_APPLY_IDENTITY_SHIM: "true" }).map((issue) => issue.variable)).toContain(
"ADAPTER_APPLY_IDENTITY_SHIM",
);
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "production" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "development" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
});
it("mantém a aplicação e o worker sujeitos à configuração operacional", () => {
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/DATABASE_URL/);
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/REDIS_URL/);
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1", DATABASE_URL: adminUrl(), REDIS_URL: "redis://host" })).toThrow(
/SUPABASE_STORAGE_BUCKET/,
);
expect(() =>
assertDeployedEnv({
ADAPTER_ENV: "staging",
ADAPTER_APPLY_IDENTITY_SHIM: "1",
DATABASE_URL: adminUrl(),
REDIS_URL: "redis://host",
NEXT_PUBLIC_SUPABASE_URL: "https://exemplo.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
SUPABASE_SERVICE_ROLE_KEY: "service",
SUPABASE_STORAGE_BUCKET: "privado",
SPREADSHEET_TEMP_TTL_SECONDS: "3600",
}),
).toThrow(/ADAPTER_STAGING_AUTH/);
const worker = readFileSync(path.join(process.cwd(), "scripts", "worker.ts"), "utf8");
const migrateScript = readFileSync(path.join(process.cwd(), "scripts", "migrate.ts"), "utf8");
expect(worker).toContain('bootstrapScript("worker")');
expect(worker).not.toContain("exitOnInvalidMigrationConfig");
expect(migrateScript).toContain("exitOnInvalidMigrationConfig");
expect(migrateScript).toContain('process.argv.includes("--plan")');
expect(migrateScript).toContain('process.argv.includes("--dry-run")');
});
});
describe("histórico do dry-run", () => {
it("não transforma falha de leitura em histórico vazio", async () => {
const db = banco(async (sql) => {
if (sql.includes("to_regclass")) return [{ name: "schema_migrations" }];
throw Object.assign(new Error("permission denied"), { code: "42501" });
});
await expect(readMigrationLedger(db)).rejects.toBeInstanceOf(MigrationLedgerError);
await expect(pendingMigrations(db, { applyIdentityShim: true })).rejects.toBeInstanceOf(MigrationLedgerError);
});
it("identifica histórico ausente sem criar a tabela", async () => {
const db = await createMemoryDb();
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
const ainda = await db.withService((query) =>
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
);
expect(ainda[0]?.name ?? null).toBeNull();
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toHaveLength(5);
await db.close();
});
it("lê o histórico íntegro depois da aplicação local", async () => {
const db = await createMemoryDb();
await migrate(db, { applyIdentityShim: true });
const ledger = await readMigrationLedger(db);
expect(ledger).toEqual({
state: "presente",
ids: [
"0001_platform_shim.sql",
"0002_spreadsheet_adapter.sql",
"0003_rls.sql",
"0004_audit_append_only.sql",
"0005_maintenance_roles.sql",
],
});
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toEqual([]);
await db.close();
});
});