diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 59d266a..fcdfb11 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,28 +1,53 @@ -import { identityShimEnabled } from "../src/config/env"; -import { migrate, pendingMigrations } from "../src/infra/db/migrate"; -import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap"; +import { adapterEnv, identityShimEnabled } from "../src/config/env"; +import { exitOnInvalidMigrationConfig } from "../src/config/fail-fast"; +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 - * antes do passo manual de migração, para que ninguém descubra o que vai rodar - * só depois de ter rodado. + * Três modos, separados de propósito: + * - `--plan` lista o manifesto no disco e não abre banco. + * - `--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"); -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) => { - const pending = await pendingMigrations(db); - console.log(`alvo: ${describeTarget()}`); - console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`); - if (pending.length === 0) { - console.log("migrations: nada pendente"); - return; - } - if (dryRun) { - console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`); - return; - } - const ran = await migrate(db); - console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente"); -}); + runWithDb("migrate", "admin", async (db) => { + const ledger = await readMigrationLedger(db); + const pending = await pendingMigrations(db); + console.log(`alvo: ${adapterEnv() === "staging" ? "postgres administrativo" : describeTarget()}`); + 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) { + console.log("migrations: nada pendente"); + return; + } + if (dryRun) { + console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`); + return; + } + const ran = await migrate(db); + console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente"); + }); +} diff --git a/src/config/env.ts b/src/config/env.ts index 7b24be3..da589b3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -138,6 +138,47 @@ export function formatConfigIssues(issues: EnvIssue[]): string { 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 { if (env.NEXT_PHASE === "phase-production-build") return true; if (env.npm_lifecycle_event === "build") return true; diff --git a/src/config/fail-fast.ts b/src/config/fail-fast.ts index 6e196bf..cedc559 100644 --- a/src/config/fail-fast.ts +++ b/src/config/fail-fast.ts @@ -1,5 +1,5 @@ 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 é @@ -21,3 +21,19 @@ export function exitOnInvalidConfig(env: EnvMap = process.env): void { 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); + } +} diff --git a/src/infra/db/migrate.ts b/src/infra/db/migrate.ts index 58fb3a9..a561692 100644 --- a/src/infra/db/migrate.ts +++ b/src/infra/db/migrate.ts @@ -84,4 +84,12 @@ export async function migrate(db: Db, options: MigrateOptions = {}): Promise { + 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`. */ export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise { const applyShim = options.applyIdentityShim ?? identityShimEnabled(); - const appliedRows = await db - .withService((query) => query.query<{ id: string }>("select id from public.schema_migrations")) - .catch(() => [] as { id: string }[]); - const applied = new Set(appliedRows.map((row) => row.id)); + const ledger = await readMigrationLedger(db); + const applied = new Set(ledger.state === "presente" ? ledger.ids : []); return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file))); } diff --git a/tests/services/postgres.test.ts b/tests/services/postgres.test.ts index f17dde2..f2070be 100644 --- a/tests/services/postgres.test.ts +++ b/tests/services/postgres.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; 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 { 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 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 BETA = "22222222-2222-4222-8222-222222222222"; const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -161,6 +168,32 @@ describe.skipIf(!enabled)("PostgreSQL real", () => { 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 () => { const pending = await pendingMigrations(db, { applyIdentityShim: false }); expect(pending).not.toContain("0001_platform_shim.sql"); diff --git a/tests/unit/migration-admin.test.ts b/tests/unit/migration-admin.test.ts new file mode 100644 index 0000000..dd9ba47 --- /dev/null +++ b/tests/unit/migration-admin.test.ts @@ -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; + 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(); + }); +});