diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 50a2e8735d..9d65e3fb61 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -47,9 +47,12 @@ interface ValueCondition extends FlagConditionBase { readonly is?: never; } -export type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; +type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; -/** The database a command talks to: `--local`, `--db-url`, or the linked project. */ +/** + * The target a command talks to: `--local`, `--db-url`, or the linked project. `seed buckets` uses + * it for its Storage target, which `--linked`/`--local` select the same way. + */ export type DbTarget = "local" | "db-url" | "linked"; /** @@ -142,11 +145,12 @@ export function compose(...fragments: ReadonlyArray): Permis /** * Adds `condition` to every operation entry in `fragment`, ahead of each entry's own `when`. It - * keeps no block `source`, so it must name a flag of the command that declares it. + * keeps no block `source`, so a flag condition must name a flag of the command that declares it, + * and a target condition a target that command can select. */ export function withCondition( fragment: PermissionFragment, - condition: FlagCondition, + condition: EntryCondition, ): PermissionFragment { return { ...fragment, @@ -157,6 +161,23 @@ export function withCondition( }; } +/** + * Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, e.g. a choice + * the user makes at a prompt. + */ +export function withContext( + fragment: PermissionFragment, + prerequisite: string, +): PermissionFragment { + return { + ...fragment, + operations: fragment.operations.map((entry) => ({ + ...entry, + context: entry.context === undefined ? prerequisite : `${prerequisite}, and ${entry.context}`, + })), + }; +} + /** * Gives every entry in `fragment` the `kind` the including command handles it with, when that * differs from the block's own (e.g. a command that carries on after a 403 on a call its diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index bf8156ec7e..28913f9c0b 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1,4 +1,3 @@ -import type { NetConnectOpts } from "node:net"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -19,20 +18,11 @@ import * as TestClock from "effect/testing/TestClock"; import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; -// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. vi.mock("node:net", (importOriginal) => - importOriginal().then((net) => ({ - ...net, - connect: (options: NetConnectOpts) => { - const host = "host" in options ? options.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { - return net.connect(options); - } - const socket = new net.Socket(); - queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); - return socket; - }, - })), + Promise.all([ + import("../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); import { @@ -53,7 +43,12 @@ import { } from "../../tests/helpers/permission-real-layers.ts"; import { applicableOperations } from "../../tests/helpers/permission-registry.ts"; import type { ActiveFlags } from "../../tests/helpers/permission-registry.ts"; -import { dbConfig, dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; +import { + dbConfig, + dbConfigLayer, + dbConfigPoolerFallback, + dbConfigResolverLayer, +} from "./db-config.layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -1866,3 +1861,120 @@ describe("dbConfigResolver (Management API calls)", () => { ); } }); + +describe("dbConfigResolver (container pooler fallback)", () => { + const ref = "fallbackrefabcdefghi"; + const poolerPath = `/v1/projects/${ref}/config/database/pooler`; + const loginRolePath = `/v1/projects/${ref}/cli/login-role`; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const connects = Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }); + const assertFallbackDrift = ( + requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, + activeFlags: ActiveFlags, + ) => + assertBlockDrift(dbConfigPoolerFallback, { + activeFlags, + requests: requests.map((r) => ({ + method: r.method, + url: `https://api.supabase.com${r.path}`, + })), + exact: true, + }); + + it.effect("fetches the pooler config, then mints a login role when no password is set", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => { + if (method === "GET" && path === poolerPath) + return Response.json(primaryPoolerConfigs(ref)); + if (method === "POST" && path === loginRolePath) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(Option.isSome(pooler)).toBe(true); + if (Option.isSome(pooler)) { + expect(pooler.value).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `cli_login_role.${ref}`, + password: "temporary-role-password", + }); + } + expect(requests).toEqual([ + { method: "GET", path: poolerPath }, + { method: "POST", path: loginRolePath }, + ]); + assertFallbackDrift(requests, { linked: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("fetches the pooler config but mints no login role when a password is set", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === poolerPath + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(pooler).toEqual( + Option.some(expect.objectContaining({ user: `postgres.${ref}`, password: "secret" })), + ); + expect(requests).toEqual([{ method: "GET", path: poolerPath }]); + assertFallbackDrift(requests, { linked: true, password: "secret" }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("returns no pooler when the pooler config is forbidden", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === poolerPath + ? Response.json({ message: "Forbidden" }, { status: 403 }) + : undefined, + ); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(Option.isNone(pooler)).toBe(true); + expect(requests).toEqual([{ method: "GET", path: poolerPath }]); + assertFallbackDrift(requests, { linked: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("--local returns no pooler and makes no Management API request", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback(dir, localFlags); + expect(Option.isNone(pooler)).toBe(true); + expect(requests).toEqual([]); + assertFallbackDrift(requests, { local: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index bc4f88b9a6..862cfb694a 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -45,50 +45,98 @@ import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; -import { block } from "./command-permissions/model.ts"; +import { block, type OperationEntry } from "./command-permissions/model.ts"; -/** - * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands - * include it with `withDbTarget`. `gen types --project-id` and `db dump`'s container fallback - * fetch the pooler config without `--project-ref`, so those commands declare that entry - * themselves. - */ -export const dbConfig = block("db-config", [ +const linkedEntries: ReadonlyArray> = [ { operationId: "v1-create-login-role", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: - "no database password from --password (an empty value counts as none), SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", + "no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", }, { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "project-ref" }], + when: [{ flag: "project-ref" }], context: "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "project-ref", oneOf: [""] }], + when: [{ flag: "project-ref", oneOf: [""] }], context: "an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-list-all-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "after three failed attempts to connect through the pooler as the temporary login role", }, { operationId: "v1-delete-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "after three failed attempts to connect through the pooler as the temporary login role", }, +]; + +/** + * The same calls as {@link dbConfig} without its target condition, for a command that reaches the + * linked path some other way: `db diff --from linked`, a `--linked` flag with no default target, or + * a target prompt. + */ +export const dbConfigLinked = block("db-config", linkedEntries); + +/** + * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands + * include it with `withDbTarget`. The container retry that `db dump` and `db pull` run is + * {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the + * pooler config without `--project-ref`, so that command declares its own entry. + */ +export const dbConfig = block( + "db-config", + linkedEntries.map((entry) => ({ + ...entry, + when: [{ dbTarget: ["linked" as const] }, ...(entry.when ?? [])], + })), +); + +/** + * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export + * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. + */ +export const dbConfigPoolerFallback = block("db-config-pooler-fallback", [ + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ dbTarget: ["linked"] }], + context: + "a linked dump, declarative export or pull diff fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", + }, + { + operationId: "v1-create-login-role", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: + "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env", + }, + { + operationId: "v1-list-all-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: "the same retry, after three failed attempts to connect as the temporary login role", + }, + { + operationId: "v1-delete-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: "the same retry, after three failed attempts to connect as the temporary login role", + }, ]); const DIRECT_PORT = 5432; @@ -266,12 +314,14 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { }; /** - * An explicitly empty `--password` asks for a temporary login role even when - * `SUPABASE_DB_PASSWORD` or the project `.env` holds one. The flag binding drops empty strings, so - * pass it to {@link resolveLinkedPassword} as the caller's password instead. + * A `--password` the caller bound on {@link DbConfigFlags} is passed to + * {@link resolveLinkedPassword} as the caller's password, so it wins over `SUPABASE_DB_PASSWORD` and + * the project `.env` just like the flag tier does, and no login role is minted for it. An + * explicitly empty `--password` asks for a temporary login role even when the env holds one; the + * flag binding drops empty strings, so this is the only way that request reaches the resolver. */ -const emptyPasswordFlag = (password: Option.Option | undefined): Option.Option => - Option.filter(password ?? Option.none(), (value) => value.length === 0); +const passwordFlag = (password: Option.Option | undefined): Option.Option => + password ?? Option.none(); const loadFailureToDbConfigError = ( error: E, @@ -690,7 +740,7 @@ export const dbConfigResolverLayer = Layer.effect( cliSettings.projectHost, cliSettings.poolerHost, flags.dnsResolver, - emptyPasswordFlag(flags.password), + passwordFlag(flags.password), { adHocProjectRef: flags.adHocProjectRef ?? false, resolveVaultSecrets, @@ -759,7 +809,7 @@ export const dbConfigResolverLayer = Layer.effect( const password = yield* resolveLinkedPassword( ref, cliSettings.workdir, - emptyPasswordFlag(flags.password), + passwordFlag(flags.password), flags.password !== undefined, ); // Container-fallback: fetch the primary pooler config from the Management API when diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 8964723a50..e789d0c667 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -1,5 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbAdvisors } from "./advisors.handler.ts"; @@ -46,6 +54,40 @@ export type DbAdvisorsFlags = CliCommand.Command.Config.Infer; export const dbAdvisorsCommand = Command.make("advisors", config).pipe( Command.withDescription("Checks database for security and performance issues."), Command.withShortDescription("Checks database for security and performance issues"), + withPermissions( + withDbTarget( + compose( + { + operations: [ + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "security"] }], + }, + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "performance"] }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + ], + noApiEffectFlags: ["level", "fail-on"], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbAdvisors(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 1d1df0a5f1..83181c6cd9 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -1,5 +1,14 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,7 +20,18 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -230,6 +250,8 @@ interface SetupOpts { performanceLints?: ReadonlyArray>; /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ + realLayers?: { readonly workdir: string }; } function setup(opts: SetupOpts = {}) { @@ -281,12 +303,22 @@ function setup(opts: SetupOpts = {}) { if (url.includes("/advisors/performance")) { return Effect.succeed(jsonResponse(request, 200, { lints: opts.performanceLints ?? [] })); } + if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}`)) { + return Effect.succeed( + jsonResponse(request, 200, { + ref: VALID_REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }), + ); + } return Effect.succeed(jsonResponse(request, 404, {})); }, }); const cliSettings = mockCommandSettings({ - workdir: "/tmp/advisors-int", + workdir: opts.realLayers?.workdir ?? "/tmp/advisors-int", accessToken: opts.loggedIn === false ? Option.none() : undefined, }); const credentials = mockCredentials({ @@ -296,12 +328,20 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( out.layer, - resolver.layer, + opts.realLayers === undefined + ? resolver.layer + : realDbConfigResolver({ + workdir: opts.realLayers.workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), connection.layer, telemetry.layer, processControl.layer, projectRef.layer, - cache.layer, + opts.realLayers === undefined + ? cache.layer + : realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), cliSettings, credentials.layer, identityStitch.layer, @@ -335,9 +375,14 @@ const flags = (over: Partial = {}): DbAdvisorsFlags => ({ describe("db advisors — local", () => { it.effect("queries the local database and prints the pretty JSON array", () => { - const { layer, out, connection } = setup({ rows: [lintRow()] }); + const { layer, out, connection, api } = setup({ rows: [lintRow()] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); + assertPermissionDrift({ + command: "db advisors", + requests: api.requests, + exact: true, + }); const expected = encodeAdvisorLints([scanAdvisorLintRow(lintRow())]); expect(out.stdoutText).toBe(expected); expect(out.stderrText).toContain("Connecting to local database..."); @@ -520,6 +565,7 @@ describe("db advisors — local", () => { }); describe("db advisors — linked", () => { + const tempRoot = useTempWorkdir("supabase-db-advisors-int-"); const securityLint = { name: "rls_disabled_in_public", title: "RLS disabled", @@ -537,6 +583,97 @@ describe("db advisors — linked", () => { cache_key: "perf", }; + it.effect( + "mints a login role, fetches both advisors and fills the cache through the real layers", + () => { + const { layer, api } = setup({ + securityLints: [securityLint], + performanceLints: [performanceLint], + args: ["db", "advisors", "--linked", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const resolverRequests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(resolverRequests, serveLinkedDatabase(VALID_REF)); + yield* dbAdvisors(flags({ linked: true, projectRef: Option.some(VALID_REF) })); + expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/security`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/performance`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + true, + ); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(resolverRequests), ...api.requests], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("makes no Management API request for --local through the real layers", () => { + const { layer, api } = setup({ + rows: [], + args: ["db", "advisors", "--local"], + realLayers: { workdir: tempRoot.current }, + }); + const resolverRequests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(resolverRequests, () => undefined); + yield* dbAdvisors(flags({ local: true })); + expect(resolverRequests).toEqual([]); + expect(api.requests).toEqual([]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "fails on a 403 from the security advisors without asking for the performance ones", + () => { + const { layer, api } = setup({ securityStatus: 403, args: ["--linked"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("all") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("unexpected security advisors status 403"); + } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/security`, + ]); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "all" }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fetches both security and performance advisors for --type all", () => { const { layer, out, api, cache } = setup({ securityLints: [securityLint], @@ -544,10 +681,16 @@ describe("db advisors — linked", () => { args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ level: Option.some("info") })); + yield* dbAdvisors(flags({ type: Option.some("all"), level: Option.some("info") })); const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "all", level: true }, + requests: api.requests, + exact: true, + }); expect(out.stdoutText).toContain("rls_disabled_in_public"); expect(out.stdoutText).toContain("unindexed_foreign_keys"); expect(cache.cached).toBe(true); @@ -661,6 +804,12 @@ describe("db advisors — linked", () => { const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(false); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "security" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -671,6 +820,12 @@ describe("db advisors — linked", () => { const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(false); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "performance" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 0ceba091b8..c01caa7cac 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -90,12 +90,13 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## API Routes (linked path, via the db-config resolver) -| Method | Path | Auth | Purpose | -| ---------- | ---------------------------------- | ------ | -------------------------------- | -| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | -| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | -| GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------- | ------ | ----------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | `--use-pgadmin --linked` performs every one of these calls natively (CLI-1968), as part of this command's own target resolve, ahead of the differ container. diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index fae503c4f5..7e4592048c 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigLinked } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -97,11 +105,39 @@ const config = { export type DbDiffFlags = CliCommand.Command.Config.Infer; +// An explicit `--from`/`--to linked` side resolves the linked project without `--linked`. +const explicitLinkedSide = (flag: "from" | "to") => + withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked"] }); + export const dbDiffCommand = Command.make("diff", config).pipe( Command.withDescription( "Compares a shadow built from supabase/migrations with a live database (--local by default, --linked, or --db-url). Declarative files under supabase/schemas are not part of this baseline. Output is printed by default; in normal mode, -f names and saves the complete diff as a migration and does not filter objects. Explicit --from/--to output is flattened review SQL, not a portable apply script.", ), Command.withShortDescription("Diffs the local database for schema changes"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "use-migra", + "use-pgadmin", + "use-pg-schema", + "use-pg-delta", + "strict-coverage", + "output", + "file", + "schema", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + explicitLinkedSide("from"), + explicitLinkedSide("to"), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbDiff(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index bbdc00e349..7c1a9ed199 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -18,11 +18,31 @@ import { import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { FAKE_SHADOW_CONTAINER_ID, VALID_REF, + VALID_TOKEN, failWriteStringMatchingFsLayer, failWriteStringOnNthCallFsLayer, withConfigEnv, @@ -142,6 +162,8 @@ interface SetupOpts { readonly statefulDocker?: boolean; readonly dirs?: ReadonlyArray; readonly files?: Readonly>; + // Runs the real resolver and linked-project cache; `workdir` must be a real directory. + readonly realLayers?: boolean; } const alwaysReadyHttpClientLayer = Layer.succeed( @@ -429,6 +451,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); + const projectHttp = recordingProjectHttpClient(VALID_REF); const baseLayer = Layer.mergeAll( unusedStackServices, // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) @@ -444,7 +467,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { env: opts.env, }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, pgDeltaEngine, edge, docker, @@ -452,9 +480,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { dockerDaemon?.layer ?? shadowSpawnerLayer, mockLocalDockerEngineUnavailableLayer, alwaysReadyHttpClientLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: shadowDbConnection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, projectRefResolver, - mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), + mockCommandSettings({ + workdir, + projectId: opts.projectId ?? Option.some("test"), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), + }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed( NetworkIdFlag, @@ -501,6 +539,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { shadowConnectedDatabases: shadowDbConnection.connectedDatabases, shadowExecCalls: shadowDbConnection.execCalls, shadowConnectRefused: shadowDbConnection.shadowConnectRefused, + projectHttp, }; } @@ -2904,3 +2943,149 @@ describe("db diff", () => { }); }); }); + +describe("db diff permissions through the real resolver and cache", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + + it.effect("mints a login role and fills the cache for --linked", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "alter table x;\n" }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff(flags({ linked: Option.some(true), projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(s.layer), + ); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db diff", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for the default local target", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "alter table x;\n" }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbDiff(flags()).pipe(Effect.provide(s.layer)); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db diff", + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + for (const side of ["from", "to"] as const) { + it.effect(`resolves the linked project for --${side} linked without --linked`, () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + const refs = + side === "from" + ? { from: "linked", to: "migrations" } + : { from: "migrations", to: "linked" }; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some(refs.from), + to: Option.some(refs.to), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: refs.from, to: refs.to, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + } + + it.effect("makes no Management API request for --from local --to migrations", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("migrations") })).pipe( + Effect.provide(s.layer), + ); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: "local", to: "migrations" }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("mints a login role for each side when both --from and --to are linked", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some("linked"), + to: Option.some("linked"), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([ + poolerRequest, + loginRoleRequest, + poolerRequest, + loginRoleRequest, + ]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: "linked", to: "linked", "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 8e0399ee43..85b29e5b94 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -24,10 +24,11 @@ image), to stdout or `--file`. ## API Routes -| Method | Path | Auth | When | -| ------ | -------------------------------------------- | ------ | ------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked` with no `DB_PASSWORD` (mint a temp postgres role) | -| GET | `/v1/projects/{ref}/network-bans` (+ DELETE) | Bearer | `--linked` pooler temp-role retry (clear self ban) | +| Method | Path | Auth | When | +| ------ | ------------------------------------------ | ------ | ------------------------------------------------------------ | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked` with no `DB_PASSWORD` (mint a temp postgres role) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | `--linked` pooler temp-role retry (list bans) | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked` pooler temp-role retry (clear self ban) | (All via the shared `DbConfigResolver` `--linked` path.) diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 5576547329..bf09694752 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -2,6 +2,14 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -110,6 +118,29 @@ export type DbDumpFlags = CliCommand.Command.Config.Infer; export const dbDumpCommand = Command.make("dump", config).pipe( Command.withDescription("Dumps data or schemas from the remote database."), Command.withShortDescription("Dumps data or schemas from the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "dry-run", + "data-only", + "use-copy", + "exclude", + "role-only", + "keep-comments", + "file", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbDump(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 43df14abb6..2b051ae9e5 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -14,11 +14,38 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -38,9 +65,14 @@ import { PROJECT_NOT_LINKED_MESSAGE, PROJECT_REF_PATTERN, } from "../../../config/project-ref.service.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts"; -import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + DbConnection, + type DbSession, + type PgConnInput, +} from "../../../command-internal/db-connection.service.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; @@ -185,6 +217,15 @@ const unusedStackApi = Layer.succeed(StackApi, { findDeleted: () => Effect.die("unused"), }); +const connectedSession: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), +}; + function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; @@ -409,6 +450,8 @@ interface SetupOpts { stdoutIsPipe?: boolean; env?: Readonly>; dockerInstalled?: boolean; + /** Runs the real resolver and linked-project cache, with `workdir` a real directory. */ + realLayers?: boolean; } function setup(opts: SetupOpts = {}) { @@ -430,23 +473,43 @@ function setup(opts: SetupOpts = {}) { }); const docker = mockDockerRun(opts); const bundled = mockBundledPostgresClient(opts); + const projectHttp = recordingProjectHttpClient(VALID_REF); + const workdir = opts.workdir ?? "/work/project"; const layer = Layer.mergeAll( cliConfigValuesTestLayer, out.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: Layer.succeed(DbConnection, { + connect: () => Effect.succeed(connectedSession), + }), + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, projectRef.layer, docker.layer, bundled.layer, unusedStackApi, mockCommandSettings({ - workdir: opts.workdir ?? "/work/project", + workdir, projectId: opts.projectId ?? Option.none(), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, runtimeInfoLayer(opts.platform ?? "linux", opts.arch), mockTty({ stdoutIsPipe: opts.stdoutIsPipe }), - processEnvLayer(opts.env ?? {}), + processEnvLayer( + opts.realLayers === true + ? { SUPABASE_ACCESS_TOKEN: VALID_TOKEN, ...opts.env } + : (opts.env ?? {}), + ), Layer.succeed( NetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), @@ -454,7 +517,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), BunServices.layer, ); - return { layer, out, telemetry, resolver, docker, bundled, cache }; + return { layer, out, telemetry, resolver, docker, bundled, cache, projectHttp }; } const flags = (over: Partial = {}): DbDumpFlags => ({ @@ -1306,3 +1369,132 @@ describe("db dump integration", () => { } }); }); + +describe("db dump permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir("supabase-db-dump-"); + const IPV6_STDERR = + 'could not translate host name "db.abcdefghijklmnopqrst.invalid" to address: No address associated with hostname'; + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + + it.effect("mints a login role and fills the cache for the default linked target", () => { + const { layer, out, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump(flags({ projectRef: Option.some(VALID_REF) })).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db dump", + activeFlags: { "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("does not mint a login role when --password is given", () => { + const { layer, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump( + flags({ + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }), + ).pipe(Effect.provide(layer)); + expect(asLines(requests)).toEqual([poolerRequest]); + assertPermissionDrift({ + command: "db dump", + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("makes no Management API request for --local and caches nothing", () => { + const { layer, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db dump", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("retries through the pooler after a container IPv6 failure", () => { + const { layer, docker, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + results: [ + { exitCode: 1, stderr: IPV6_STDERR }, + { exitCode: 0, stdout: "CREATE SCHEMA x;\n" }, + ], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + const fs = yield* FileSystem.FileSystem; + // No --project-ref: the linked ref comes from the saved file, so only the fallback block + // accounts for the pooler config request. + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump(flags()).pipe(Effect.provide(layer)); + expect(docker.allOpts).toHaveLength(2); + expect(docker.allOpts[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); + expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db dump", + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index dd874159fa..880ece2f40 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -1,5 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; @@ -52,6 +60,16 @@ export type DbLintFlags = CliCommand.Command.Config.Infer; export const dbLintCommand = Command.make("lint", config).pipe( Command.withDescription("Checks local database for typing error."), Command.withShortDescription("Checks local database for typing error"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["schema", "level", "fail-on"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbLint(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index 378adf1c73..86f99868a0 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -1,13 +1,36 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, + VALID_TOKEN, + mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; @@ -174,6 +197,8 @@ interface SetupOpts { listFails?: boolean; /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ + realLayers?: { readonly workdir: string }; } function setup(opts: SetupOpts = {}) { @@ -191,18 +216,30 @@ function setup(opts: SetupOpts = {}) { const processControl = mockProcessControl(); const projectRef = mockProjectRef(); const cache = mockLinkedProjectCacheTracked(); + const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, - resolver.layer, + opts.realLayers === undefined + ? resolver.layer + : realDbConfigResolver({ + workdir: opts.realLayers.workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), connection.layer, telemetry.layer, processControl.layer, projectRef.layer, - cache.layer, + opts.realLayers === undefined + ? cache.layer + : realLinkedProjectCache({ + httpClient: http.layer, + cliSettings: mockCommandSettings({ workdir: opts.realLayers.workdir }), + }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); - return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache }; + return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } const flags = (over: Partial = {}): DbLintFlags => ({ @@ -216,6 +253,8 @@ const flags = (over: Partial = {}): DbLintFlags => ({ }); describe("db lint", () => { + const tempRoot = useTempWorkdir("supabase-db-lint-int-"); + it.effect("lints the named schema and prints parsed issues to stdout", () => { const { layer, out, connection } = setup({ schemas: [], @@ -498,6 +537,128 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + const { layer, http } = setup({ + isLocal: false, + checkRows: { public: [] }, + args: ["db", "lint", "--linked", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbLint( + flags({ schema: ["public"], linked: true, projectRef: Option.some(VALID_REF) }), + ); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(http.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db lint", + activeFlags: { linked: true, "project-ref": VALID_REF, schema: true }, + requests: [...asRecordedRequests(requests), ...http.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, http } = setup({ + checkRows: { public: [] }, + args: ["db", "lint", "--local"], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbLint(flags({ schema: ["public"], local: true })); + expect(requests).toEqual([]); + expect(http.calls).toEqual([]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db lint", + activeFlags: { local: true, schema: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, http } = setup({ + checkRows: { public: [] }, + args: ["db", "lint"], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbLint(flags({ schema: ["public"] })); + expect(requests).toEqual([]); + expect(http.calls).toEqual([]); + assertPermissionDrift({ + command: "db lint", + activeFlags: { schema: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("--linked=false still reaches the linked database through the real resolver", () => { + const { layer, http } = setup({ + isLocal: false, + checkRows: { public: [] }, + args: ["db", "lint", "--linked=false", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbLint(flags({ schema: ["public"], projectRef: Option.some(VALID_REF) })); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db lint", + activeFlags: { linked: false, "project-ref": VALID_REF, schema: true }, + requests: [...asRecordedRequests(requests), ...http.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + it.effect("does not write the linked-project cache for a local run", () => { const { layer, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 44d08319ef..6024c02c57 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -113,13 +113,15 @@ at all, so nothing is cached for it. ## API Routes / DB -| Method | Path / SQL | Auth | Purpose | -| ------ | --------------------------------------------------- | ------ | -------------------------------- | -| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | -| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | -| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | -| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | -| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (on confirmation) | +| Method | Path / SQL | Auth | Purpose | +| ------ | --------------------------------------------------- | ------ | ----------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | +| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (on confirmation) | ## Environment Variables diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 114ed17059..46abf32ec6 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -1,6 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -82,6 +90,26 @@ export const dbPullCommand = Command.make("pull", config).pipe( "Migration mode compares supabase/migrations with the selected live database (--linked by default), writes the complete difference as migration files, and may record them in that database's migration history. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", ), Command.withShortDescription("Pull schema from the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "declarative", + "use-pg-delta", + "diff-engine", + "strict-coverage", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbPull(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index a2d30f46af..400fb7e7d1 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -14,10 +14,38 @@ import { } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { VALID_REF, + VALID_TOKEN, withConfigEnv, withEmptyConfigEnv, withEnvVar, @@ -50,6 +78,7 @@ import { ProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/project-ref.service.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; @@ -158,6 +187,8 @@ interface SetupOpts { // issues after the migration file is already on disk); the shadow's own internal // migration replay is unaffected. Exercises `DbPullWriteError`'s `writtenSoFar`. readonly historyUpdateFailWith?: string; + // Runs the real resolver and linked-project cache; `workdir` must be a real directory. + readonly realLayers?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -437,6 +468,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); + const projectHttp = recordingProjectHttpClient(VALID_REF); const baseLayer = Layer.mergeAll( unusedStackServices, // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) @@ -452,7 +484,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { env: opts.env, }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, pgDeltaEngine, edge, docker, @@ -462,9 +499,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbConnection, dockerDaemon?.layer ?? shadowSpawner.layer, alwaysReadyHttpClientLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, projectRefResolver, - mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), + mockCommandSettings({ + workdir, + projectId: opts.projectId ?? Option.some("test"), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), + }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin( opts.stdinIsTty ?? false, @@ -503,6 +550,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, spawnedBeforeEdgeRun, cache, + projectHttp, }; } @@ -2408,3 +2456,149 @@ describe("db pull", () => { }); }); }); + +describe("db pull permissions through the real resolver and cache", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const savedRef = { "supabase/.temp/project-ref": VALID_REF }; + + it.effect("mints a login role and fills the cache for the default linked target", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPull( + flags({ declarative: Option.some(true), projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(s.layer)); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(s.projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + cacheRequest, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role when --password is given", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPull( + flags({ + declarative: Option.some(true), + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest]); + assertPermissionDrift({ + command: "db pull", + activeFlags: { + declarative: true, + linked: true, + "project-ref": VALID_REF, + password: true, + }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local and caches nothing", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbPull(flags({ declarative: Option.some(true), local: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true, local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("retries the initial dump through the pooler after a container IPv6 failure", () => { + // No --project-ref: the ref comes from the saved file, so only the fallback block accounts + // for the pooler config request. + const s = setup(tmp.current, { + realLayers: true, + files: savedRef, + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpStdout: "", + edgeStdout: "", + yes: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + // The initial pg_dump seed only runs on the migra engine; pg-delta is the default. + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.provide(s.layer), + Effect.flip, + ); + expect(error.message).toBe("No schema changes found"); + expect(s.dumpCalls).toHaveLength(2); + expect(s.dumpCalls[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); + expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db pull", + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index e6b1e16566..216d66078e 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; @@ -70,6 +78,25 @@ export const dbPushCommand = Command.make("push", config).pipe( "Push new migrations to the remote database. Vault secrets from config.toml are updated before migrations unless --skip-vault is set.", ), Command.withShortDescription("Push new migrations to the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "include-all", + "include-roles", + "include-seed", + "skip-vault", + "dry-run", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbPush(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 3a7ac9358d..5028bdffdf 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -3,11 +3,31 @@ import { createHash } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -177,6 +197,8 @@ function setup( // fired as part of `resolve()`'s own connection work, strictly before `dbPushCore` // runs; `mockResolver` is otherwise silent, so tests pin output ordering against it. simulateInitialisingLoginRole?: boolean; + /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ + realLayers?: boolean; }, ) { const workdirLayer = Layer.effectDiscard( @@ -228,6 +250,7 @@ function setup( } : undefined, }); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( workdirLayer, out.layer, @@ -237,7 +260,13 @@ function setup( env: opts.env, }), conn.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: conn.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, mockCommandSettings({ workdir, ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), @@ -252,7 +281,12 @@ function setup( Layer.succeed(DnsResolverFlag, "native"), projectRefLayer, telemetry.layer, - linkedCache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedCache.layer, ); return { layer, @@ -261,6 +295,7 @@ function setup( telemetry, linkedCache, resolver, + projectHttp, }; } @@ -1138,6 +1173,107 @@ describe("db push", () => { }); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + // No target flag: push defaults to the linked project. + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--project-ref", VALID_REF], + isLocal: false, + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db push", + activeFlags: { "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role when --password is given", () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--linked", "--project-ref", VALID_REF, "--password", "secret"], + isLocal: false, + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + linked: true, + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "db push", + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local"], + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db push", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + it.effect("surfaces an apply error with statement context", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 86ff277730..86d0acc0f1 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1,4 +1,3 @@ -import type { NetConnectOpts } from "node:net"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -19,20 +18,11 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { vi } from "vitest"; -// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. vi.mock("node:net", (importOriginal) => - importOriginal().then((net) => ({ - ...net, - connect: (options: NetConnectOpts) => { - const host = "host" in options ? options.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { - return net.connect(options); - } - const socket = new net.Socket(); - queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); - return socket; - }, - })), + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); import { diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 67b5c033a2..21ff473bcb 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -54,6 +62,16 @@ export type DbResetFlags = CliCommand.Command.Config.Infer; export const dbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["no-seed", "sql-paths", "version", "last"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbReset(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 9f11c30881..d9c768a32e 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -18,6 +18,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, @@ -28,6 +36,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, + VALID_TOKEN, jsonResponse, withConfigEnv, withEmptyConfigEnv, @@ -41,6 +50,17 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; @@ -913,6 +933,8 @@ function setup( stackUnavailableAfterReset?: boolean; stackStatusFailure?: StackError; httpClient?: Layer.Layer; + /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ + realLayers?: boolean; }, ) { const workdirLayer = Layer.effectDiscard( @@ -997,6 +1019,7 @@ function setup( : Effect.succeed(jsonResponse(request, matched.status ?? 200, matched.body ?? {})); }), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const seedFlagInputs = [ ...(opts.noSeed === true ? [flagInput("db.seed.enabled", "no-seed", false)] : []), ...(opts.sqlPaths === undefined @@ -1010,7 +1033,13 @@ function setup( out.layer, configValuesLayer({ output: out.layer, flags: seedFlagInputs, env: opts.env }), conn.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: conn.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, mockCommandSettings({ workdir }), BunServices.layer, child.layer, @@ -1048,7 +1077,12 @@ function setup( Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, opts.debug ?? false), telemetry.layer, - linkedCache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedCache.layer, ...(opts.stackBackend === true && catalog !== undefined ? [stackBackendLayer("stack"), stackApi.layer, catalog.layer] : []), @@ -1064,6 +1098,7 @@ function setup( stackApi, catalogApplied: catalog?.applied ?? [], requests, + projectHttp, }; } @@ -2651,6 +2686,105 @@ describe("db reset", () => { }); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + const { layer, conn, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--linked", "--project-ref", VALID_REF], + confirm: [true], + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbReset({ + ...DEFAULT_FLAGS, + linked: true, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(conn.execs.some((sql) => sql.includes("drop schema if exists"))).toBe(true); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db reset", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + realLayers: true, + }); + const requests: Array = []; + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + assertPermissionDrift({ command: "db reset", requests: [], exact: true }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + realLayers: true, + }); + const requests: Array = []; + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db reset", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ), + ); + }, + ); + it.effect("fails a remote reset before dropping schemas on an undecryptable secret", () => { // Every secret is decrypted while loading config, before the reset runs, so an // undecryptable secret must abort before any destructive work. diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts index a8ebfe5066..9dfed8e9aa 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts @@ -2,6 +2,15 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, + type PermissionFragment, +} from "../../../../../command-internal/command-permissions/model.ts"; +import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../../../config/cli-config-keys.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; @@ -67,6 +76,13 @@ const config = { }), } as const; +// Without a target flag, a run whose workdir has migrations can ask where to generate from. +const noTargetFlag = (fragment: PermissionFragment) => + (["db-url", "local", "linked"] as const).reduce( + (current, flag) => withCondition(current, { flag, present: false }), + fragment, + ); + // `--no-cache` is a shared flag on the `declarative` group (read from the parent), // so the handler input merges it in alongside the leaf's own flags. export type DbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer & { @@ -79,6 +95,34 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output-dir to stage an export without changing the configured declarative path. In non-interactive use, pass --local, --linked, or --db-url explicitly.", ), Command.withShortDescription("Generate declarative schema from a database"), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: [ + "overwrite", + "output-dir", + "reset", + "schema", + "no-cache", + "strict-coverage", + ], + }, + withCondition(compose(dbConfigLinked, linkedProjectCache), { flag: "linked" }), + noTargetFlag( + compose( + withContext( + dbConfigLinked, + "migrations exist and the user picks the linked project in the target prompt, which needs an interactive terminal or --yes and a linked project ref", + ), + withContext( + linkedProjectCache, + "migrations exist and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", + ), + ), + ), + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 13397bd05c..c5c3d6e89d 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -2,7 +2,31 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; import { StackError, type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../../../tests/helpers/permission-real-layers.ts"; import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts"; import { @@ -21,6 +45,8 @@ import { } from "../../../../../../tests/helpers/mocks.ts"; import { configValuesLayer } from "../../../../../../tests/helpers/config-values-layer.ts"; import { + VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, @@ -77,6 +103,8 @@ interface SetupOpts { exportFails?: boolean; staleLocalImage?: boolean; stackBackend?: boolean; + /** Runs the real resolver and linked-project cache; `workdir` must be a real directory. */ + realLayers?: boolean; } /** What the handler handed the engine for one `exportDeclarativeSchema` call. */ @@ -308,6 +336,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, configValuesLayer({ @@ -318,11 +347,22 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, seam, engine, mockLocalDockerEngineUnavailableLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: dbConn, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, dbConn, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), @@ -360,6 +400,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbExec, engineExportCalls, resolverCalls, + projectHttp, localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; @@ -1267,3 +1308,159 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); }); + +describe("db schema declarative generate permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir(); + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + const saveLinkedRef = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + }); + + it.effect("mints a login role and fills the cache for --linked", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineExportCalls).toHaveLength(1); + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { linked: true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role for --linked with --password", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate( + flags({ linked: Option.some(true), password: Option.some("secret") }), + ).pipe(Effect.provide(s.layer)); + expect(requests).toEqual([]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { linked: true, password: true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* stubManagementApi(requests, () => undefined); + yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --db-url", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* stubManagementApi(requests, () => undefined); + yield* dbSchemaDeclarativeGenerate( + flags({ dbUrl: Option.some("postgres://alice:secret@example.com:6543/appdb") }), + ).pipe(Effect.provide(s.layer)); + expect(s.engineExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { "db-url": true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "mints a login role and fills the cache when the prompt picks the linked project", + () => { + const s = setup(tmp.current, { + realLayers: true, + stdinIsTty: true, + projectId: Option.some(VALID_REF), + promptSelectResponses: ["linked"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate(flags()).pipe(Effect.provide(s.layer)); + expect(s.out.promptSelectCalls[0]?.options.map((o) => o.value)).toEqual([ + "local", + "linked", + "custom", + ]); + // The prompt path's calls carry a prerequisite, so the exact check skips them: assert them + // directly. + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative generate", + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); +}); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts index 6923e34c73..3742498371 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../../../command-internal/command-permissions/model.ts"; +import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../../../command-internal/schema-flags.ts"; @@ -58,6 +62,30 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", ), Command.withShortDescription("Generate a new migration from declarative schema"), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: [ + "schema", + "file", + "name", + "apply", + "no-apply", + "no-cache", + "strict-coverage", + ], + }, + withContext( + dbConfigLinked, + "no declarative schema files yet, migrations exist, and the user picks the linked project in the bootstrap target prompt", + ), + withContext( + linkedProjectCache, + "the bootstrap runs with migrations present and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", + ), + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 7c6242477c..3415f1ecc7 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -12,8 +12,32 @@ import { Stream, Redacted, } from "effect"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../../../tests/helpers/permission-real-layers.ts"; import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts"; import { alwaysReadyHttpClientLayer, @@ -31,6 +55,8 @@ import { } from "../../../../../../tests/helpers/mocks.ts"; import { configValuesLayer } from "../../../../../../tests/helpers/config-values-layer.ts"; import { + VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, @@ -102,6 +128,8 @@ interface SetupOpts { removals?: PgDeltaRemovalSummary; planErrors?: ReadonlyArray; stackBackend?: boolean; + /** Runs the real resolver and linked-project cache; `workdir` must be a real directory. */ + realLayers?: boolean; } const SYNC_STACK_ID = "e".repeat(64); @@ -363,6 +391,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }).pipe(Effect.provide(BunServices.layer)), }), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, configValuesLayer({ @@ -373,12 +402,23 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, seam, engine, mockLocalDockerEngineUnavailableLayer, dbConn, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: dbConn, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), @@ -416,6 +456,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbConnectPorts, cache, telemetry, + projectHttp, localPostgresImageChecks, declarativeExportCalls, planDebug, @@ -1581,3 +1622,96 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); }); + +describe("db schema declarative sync permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir(); + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + const saveLinkedRef = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + }); + + it.effect( + "the bootstrap mints a login role and fills the cache when it picks the linked project", + () => { + const s = setup(tmp.current, { + realLayers: true, + experimental: true, + stdinIsTty: true, + diffSql: "", + projectId: Option.some(VALID_REF), + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const exit = yield* Effect.exit( + dbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ).pipe(Effect.provide(s.layer)); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); + // The bootstrap's calls carry a prerequisite, so the exact check skips them: assert them + // directly. + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db schema declarative sync", + activeFlags: { "no-cache": true, "no-apply": true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("the bootstrap fills the cache but mints no login role when it picks local", () => { + const s = setup(tmp.current, { + realLayers: true, + experimental: true, + stdinIsTty: true, + diffSql: "", + projectId: Option.some(VALID_REF), + promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] + promptSelectResponses: ["local"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const exit = yield* Effect.exit( + dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), + ).pipe(Effect.provide(s.layer)); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative sync", + activeFlags: { "no-apply": true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/db/start/start.command.ts b/apps/cli/src/commands/db/start/start.command.ts index 9cfdffe54d..0c5e2593c5 100644 --- a/apps/cli/src/commands/db/start/start.command.ts +++ b/apps/cli/src/commands/db/start/start.command.ts @@ -1,6 +1,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -20,6 +21,7 @@ export type DbStartFlags = CliCommand.Command.Config.Infer; export const dbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), + withPermissions({ operations: [], noApiEffectFlags: ["from-backup"] }), Command.withHandler((flags) => dbStart(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 21ea35168f..a60c87747e 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -23,6 +23,7 @@ import { mockProcessControl, mockRuntimeInfo, } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { @@ -246,12 +247,19 @@ function runningCheckFailsRoute( }; } -const alwaysReadyHttpClientLayer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), - ), -); +function alwaysReadyHttpClient() { + const calls: Array<{ readonly method: string; readonly url: string }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + calls.push({ method: request.method, url: request.url }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ); + }), + ); + return { layer, calls }; +} /** PG15+ (this suite's default) never calls `exec`/`query` directly — its schema init is three one-shot `DockerRun` jobs instead. */ function fakeDbSession() { @@ -330,6 +338,7 @@ function setup(opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); const out = mockOutput({ format: opts.format ?? "text" }); + const http = alwaysReadyHttpClient(); const telemetry = mockTelemetryStateTracked(); const cliSettings = mockCommandSettings({ workdir }); const baseRoute = opts.route ?? defaultRoute(); @@ -377,7 +386,7 @@ function setup(opts: SetupOpts = {}) { telemetry.layer, child.layer, mockLocalDockerEngineUnavailableLayer, - alwaysReadyHttpClientLayer, + http.layer, dbConnection, unusedStackApi, dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), @@ -406,6 +415,7 @@ function setup(opts: SetupOpts = {}) { child, dbSession, catalogApplied, + httpCalls: http.calls, get connectAttempts() { return connectAttempts; }, @@ -472,7 +482,7 @@ describe("db start", () => { it.effect( "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", () => { - const { layer, out, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + const { layer, out, child, httpCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Starting database...\n"); @@ -483,6 +493,7 @@ describe("db start", () => { expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); expect(yield* readCurrentBranch(tempRoot.current)).toBe("main"); expect(out.stderrText).not.toContain("Finished"); + assertPermissionDrift({ command: "db start", requests: httpCalls, exact: true }); }); }, ); @@ -673,7 +684,7 @@ describe("db start", () => { ); it.effect("resolves a relative --from-backup against the caller cwd, not the workdir", () => { - const { layer, child } = setup({ + const { layer, child, httpCalls } = setup({ route: freshVolumeRoute(defaultRoute()), cwd: "/caller/here", }); @@ -681,6 +692,12 @@ describe("db start", () => { yield* dbStart(flags("dump.sql")).pipe(Effect.provide(layer)); const args = createArgs(child.spawned); expect(bindsFromCreateArgs(args ?? [])).toContain("/caller/here/dump.sql:/etc/backup.sql:ro"); + assertPermissionDrift({ + command: "db start", + activeFlags: { "from-backup": true }, + requests: httpCalls, + exact: true, + }); }); }); diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 847a2a5aaa..2944ec8082 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, TEST_DB_SHORT, @@ -19,6 +22,7 @@ import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), + withPermissions(withDbTarget(dbConfig, { default: "local" })), Command.withHandler(runTestDbCommand), // `["db", "test"]`, not `["test", "db"]`: the `cli_command_executed` // telemetry records the actual invoked command path, which differs by diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index b007d7d558..d6f1030b76 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -9,6 +9,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { CliOutput, Command } from "effect/cli"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, @@ -18,10 +26,21 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { + VALID_TOKEN, mockCommandSettings, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -154,10 +173,17 @@ const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { pid: 1234, }); +const REF = "abcdefghijklmnopqrst"; + interface SetupOpts { format?: "text" | "json" | "stream-json"; exitCode?: number; stdout?: ReadonlyArray; + /** The command-line arguments the handler reads its database target from. */ + args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` against the temp `workdir`; the test serves its Management API calls. */ + realDbConfig?: boolean; + workdir?: string; } function setup(opts: SetupOpts = {}) { @@ -168,6 +194,7 @@ function setup(opts: SetupOpts = {}) { const connection = mockDbConnection(); const docker = mockDockerRun({ exitCode: opts.exitCode, stdout: opts.stdout }); const args = ["db", "test"]; + const workdir = opts.workdir ?? "/work/project"; const layer = Layer.mergeAll( cliConfigValuesTestLayer, unusedStackServices, @@ -175,18 +202,24 @@ function setup(opts: SetupOpts = {}) { processControl.layer, analytics.layer, telemetry.layer, - mockResolver(), + opts.realDbConfig === true + ? realDbConfigResolver({ + workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : mockResolver(), connection.layer, docker.layer, Layer.succeed(BundledPostgresClient, { run: () => Effect.die("bundled postgres client unused"), }), - mockCommandSettings({ workdir: "/work/project", projectId: Option.none() }), + mockCommandSettings({ workdir, projectId: Option.none() }), runtimeInfoLayer, Layer.succeed(DebugFlag, false), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), Stdio.layerTest({ args: Effect.succeed(args) }), commandRuntimeLayer(["db", "test"]).pipe(Layer.provide(BunServices.layer)), BunServices.layer, @@ -203,6 +236,8 @@ const flags = (): TestDbFlags => ({ }); describe("db test (alias) integration", () => { + const tempRoot = useTempWorkdir("supabase-db-test-int-"); + it.effect("runs pgTAP through the alias exactly like `test db`", () => { const { layer, connection, docker } = setup(); return Effect.gen(function* () { @@ -215,6 +250,80 @@ describe("db test (alias) integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("mints a login role through the real DB config resolver for --linked", () => { + const { layer, docker } = setup({ + args: ["db", "test", "--linked", "--project-ref", REF], + realDbConfig: true, + workdir: tempRoot.current, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(REF)); + yield* runTestDbCommand({ + ...flags(), + linked: true, + local: false, + projectRef: Option.some(REF), + }); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${REF}/config/database/pooler`, + `POST /v1/projects/${REF}/cli/login-role`, + ]); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + assertPermissionDrift({ + command: "db test", + activeFlags: { linked: true, "project-ref": REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, docker } = setup({ realDbConfig: true, workdir: tempRoot.current }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* runTestDbCommand(flags()); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + expect(requests).toEqual([]); + assertPermissionDrift({ command: "db test", requests: [], exact: true }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local through the real resolver", () => { + const { layer, docker } = setup({ + args: ["db", "test", "--local"], + realDbConfig: true, + workdir: tempRoot.current, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* runTestDbCommand(flags()); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + expect(requests).toEqual([]); + assertPermissionDrift({ + command: "db test", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + it.effect( "dispatches through the real `dbTestCommand` and records `db test`, not `test db`, as the telemetry command", () => { diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index c2fb10bb13..7ca68bc592 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -4,6 +4,14 @@ import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { storageCredentials } from "../../../command-internal/storage-credentials.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SeedLinkedFlag, SeedLocalFlag } from "../seed.flags.ts"; @@ -31,6 +39,15 @@ export type BucketsFlags = { export const bucketsCommand = Command.make("buckets", config).pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["project-ref"] }, + withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((leafFlags) => Effect.gen(function* () { // Enforce --local/--linked mutual exclusivity before instrumentation, so a diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 5225b6a09b..5bcf23923a 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -24,6 +24,7 @@ import { transportFailure, withEnvVar, mockCommandSettings, + mockCommandPlatformApi, mockCommandPlatformApiService, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -52,6 +53,8 @@ import { type SetupStorageStackApiOptions, } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; interface MockRoute { @@ -105,6 +108,8 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( /** Selects the `stack` backend; omitted/false keeps the legacy default. */ readonly stackBackend?: boolean; readonly stackApi?: SetupStorageStackApiOptions; + /** Records the Management API requests through the real factory and runs the real linked-project cache. */ + readonly recordManagementApi?: boolean; }, ) { const fs = yield* FileSystem.FileSystem; @@ -220,6 +225,26 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }, }); + const recorded = + opts.recordManagementApi === true + ? mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) + ? jsonResponse(request, 200, { + ref: projectRefRef, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }) + : request.method === "GET" && + request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) + ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) + : jsonResponse(request, 404, { message: "no mock route" }), + ), + }) + : undefined; + const stackApi = buildStorageStackApi(workdir, opts.stackApi); const layer = Layer.mergeAll( @@ -237,15 +262,30 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), Layer.succeed(YesFlag, opts.yes ?? false), projectRefLayer, - Layer.succeed(CommandPlatformApiFactory, { - make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), - }), - linkedCache.layer, + recorded === undefined + ? Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), + }) + : recorded.factoryLayer, + recorded === undefined + ? linkedCache.layer + : realLinkedProjectCache({ + httpClient: recorded.httpClientLayer, + cliSettings: mockCommandSettings({ workdir }), + }), unusedStackServices, ...(opts.stackBackend === true ? [stackBackendLayer("stack"), stackApi.layer] : []), ); - return { layer, out, requests, telemetry, linkedCache, stackCalls: stackApi.stackCalls }; + return { + layer, + out, + requests, + telemetry, + linkedCache, + stackCalls: stackApi.stackCalls, + managementRequests: recorded?.requests ?? [], + }; }); const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; @@ -2103,6 +2143,94 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + Effect.gen(function* () { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const fs = yield* FileSystem.FileSystem; + yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(requests.length).toBeGreaterThan(0); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: true }, + requests: managementRequests, + exact: true, + }); + }).pipe(seedScenario), + ); + + it.effect("asks for no api keys when SUPABASE_AUTH_SERVICE_ROLE_KEY is set", () => + seedScenario( + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "env-service-role-key", + Effect.gen(function* () { + const { layer, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); + expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: true }, + requests: managementRequests, + }); + }), + ), + ), + ); + + it.effect("makes no Management API request for the local target", () => + Effect.gen(function* () { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const fs = yield* FileSystem.FileSystem; + yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests.length).toBeGreaterThan(0); + expect(managementRequests).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "seed buckets", + requests: managementRequests, + exact: true, + }); + }).pipe(seedScenario), + ); + it.effect( "--project-ref --linked seeds the project given by the flag, overriding VALID_REF", () => @@ -2161,9 +2289,10 @@ describe("seed buckets", () => { it.effect("--linked=false still takes the linked path (flag presence, not value)", () => Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, + recordManagementApi: true, args: ["seed", "buckets", "--linked=false"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -2179,6 +2308,18 @@ describe("seed buckets", () => { expect(requests.every((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe( true, ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: false }, + requests: managementRequests, + exact: true, + }); }).pipe(seedScenario), ); diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index c851417adc..8ddea5a6ac 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.81.5 AS storage +FROM supabase/storage-api:v1.82.0 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 0e595022c1..e2260745f0 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -30,23 +30,12 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "config pull", "config push", - "db advisors", "db branch create", "db branch delete", "db branch list", "db branch switch", - "db diff", - "db dump", - "db lint", - "db pull", - "db push", "db remote changes", "db remote commit", - "db reset", - "db schema declarative generate", - "db schema declarative sync", - "db start", - "db test", "domains activate", "domains create", @@ -145,8 +134,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "secrets set", "secrets unset", - "seed buckets", - "services", "snippets download", diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index 72b98ea194..ad6abc4ef8 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -1,6 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { ConfigProvider, Effect, Layer, Option } from "effect"; -import type * as HttpClient from "effect/http/HttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { dbConfigLayer } from "../../src/command-internal/db-config.layer.ts"; import type { DbConfigResolver } from "../../src/command-internal/db-config.service.ts"; @@ -54,7 +55,7 @@ export function realLinkedProjectCache(opts: { /** * The real `DbConfigResolver` on the linked path: the direct host (`db..invalid`) is - * unreachable once the test file mocks `node:net` to refuse it, so the resolver falls back to + * unreachable once the test file mocks `node:net` with `refuseInvalidDbHosts`, so the resolver falls back to * the pooler and mints a login role through {@link stubManagementApi}. Provide the same `env` * to the running effect as a `ConfigProvider`, since the resolver reads it when it resolves. */ @@ -144,3 +145,70 @@ export const primaryPoolerConfigs = (ref: string) => [ pool_mode: "transaction", }, ]; + +/** A Management API request as {@link stubManagementApi} records it. */ +export interface StubbedRequest { + readonly method: string; + readonly path: string; +} + +/** The recorded requests in the shape the drift check takes. */ +export const asRecordedRequests = (requests: ReadonlyArray) => + requests.map(({ method, path }) => ({ method, url: `https://api.supabase.com${path}` })); + +/** Serves the pooler config and a login role for `ref`, which is what the resolver asks for on the linked path. */ +export const serveLinkedDatabase = + (ref: string) => + (method: string, path: string): Response | undefined => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPoolerConfigs(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }; + +/** Provides `env` to the effect as a `ConfigProvider`, the way {@link realDbConfigResolver} reads it. */ +export const provideEnv = (env: Record) => + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), + ); + +/** + * An `HttpClient` that records each request and answers the project lookup for `ref`, which is + * what the real linked-project cache fetches. + */ +export function recordingProjectHttpClient(ref: string) { + const calls: Array<{ readonly method: string; readonly url: string }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + calls.push({ method: request.method, url: request.url }); + const found = request.method === "GET" && request.url.endsWith(`/v1/projects/${ref}`); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + found + ? Response.json({ + ref, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }) + : Response.json({ message: "unexpected request" }, { status: 404 }), + ), + ); + }), + ); + return { + layer, + get calls() { + return calls; + }, + }; +} diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts new file mode 100644 index 0000000000..a989c82d89 --- /dev/null +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -0,0 +1,47 @@ +import type * as NodeNet from "node:net"; +import { Effect } from "effect"; + +/** The switch a test file shares with its `node:net` mock; create it with `vi.hoisted`. */ +export interface DirectHostToggle { + directHostReachable: boolean; +} + +/** + * Wraps `node:net` so a connection to a `db..invalid` fixture host fails at once instead of + * waiting on ambient DNS or TCP timing. Use it from a test file's `vi.mock("node:net", ...)` + * factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a + * successful connect instead, which fakes the TCP reachability probe only (nothing is dialed). + */ +export function refuseInvalidDbHosts( + net: typeof NodeNet, + toggle: DirectHostToggle = { directHostReachable: false }, +) { + return { + ...net, + connect: (connectOptions: NodeNet.NetConnectOpts) => { + const host = "host" in connectOptions ? connectOptions.host : undefined; + if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { + return net.connect(connectOptions); + } + const socket = new net.Socket(); + queueMicrotask(() => + toggle.directHostReachable + ? socket.emit("connect") + : socket.emit("error", new Error("fixture connection refused")), + ); + return socket; + }, + }; +} + +/** Turns the toggle on for the enclosing scope and back off when it closes, even on failure. */ +export const withReachableDirectHost = (toggle: DirectHostToggle) => + Effect.acquireRelease( + Effect.sync(() => { + toggle.directHostReachable = true; + }), + () => + Effect.sync(() => { + toggle.directHostReachable = false; + }), + ); diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index bd4ef400e6..184195ea71 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.81.5", + upstreamVersion: "v1.82.0", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.81.5-r0@sha256:0d200e4c0b47f5dfbede9b850429543a5363c0d1798e9c5ca670015766dc99b6", - upstreamImage: "supabase/storage-api:v1.81.5", + "ghcr.io/supabase/cli/storage:v1.82.0-r0@sha256:c71adb01633ce0147162f53fd5d4fa861fcf2707586707f6d88acfaa8db2eeb4", + upstreamImage: "supabase/storage-api:v1.82.0", natives: { "darwin-arm64": { - archive: "d7a7d4ea3743460b2cb117ff4de968ac6e6d66f44cffeaca7f3683412767b6d9", - manifest: "da0629092a687b83b47b9e44351a624a500839442505515b0e04ba7384fa92b3", + archive: "e225cd6ce14cd8ee1ede02c8e3b14e366ae2342920d904b6bd30727cbc1fd6ab", + manifest: "b2e126721482284b60cb32d82b530e17e9603cbe95cbfcc35dcb7fcedad0696d", }, "linux-amd64": { - archive: "6cab4b6b93e123c4e8e9d4808d81c68658a1563a93874c64d506f740c8acf3a4", - manifest: "4eb224328de931085948c0c769b5c95cf52e9e1292418672f49199e967623c50", + archive: "de14d47658694a3d084d26bd12c0d2ec8ff30a7abbc1dcdcbc7fa1b098010360", + manifest: "5de5ef546a2f579b6c2dd025eaf9052013849218db80449b87feda23b8d8db59", }, "linux-arm64": { - archive: "8b69c856197413f2ebba455f7ad711568be9d9600ede90240eb6fc9ce8e47c6e", - manifest: "d6650aaf8c78fab72187171e996444b6e58c0a47f24e02dd17549dcdf0262482", + archive: "3e436461a05a18579da27b3734df0c367195731046c54044a0f81e442dccec39", + manifest: "a9eed25be05723dc34a2f7240cf8127c3d3c85fbfb82fae903f470122debf2fa", }, }, },