Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 4 additions & 118 deletions .oxlintrc.effect.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,134 +8,20 @@
"**",
"!packages/config/**",
"!packages/stack/**",
"!apps/cli/src/auth/**",
"!apps/cli/src/cli/**",
"!apps/cli/src/commands/**",
"!apps/cli/src/config/**",
"!apps/cli/src/docs/**",
"!apps/cli/src/globals.d.ts",
"!apps/cli/src/main.ts",
"!apps/cli/src/output/**",
"!apps/cli/src/shared/**",
"!apps/cli/src/telemetry/**",
"!apps/cli/src/**",
// Last match wins across the whole list: keep bare re-exclusions after every
// `!` entry that would otherwise re-include them.
"apps/cli/src/shared/cli/bin.ts",
"apps/cli/src/shared/compute/stacks/**",
"apps/cli/src/shared/feedback/database.types.ts",
"apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts",
"apps/cli/src/command-internal/db-bootstrap/local-db-running.ts",
"!apps/cli/tests/helpers/branches-live.ts",
"!apps/cli/tests/helpers/compute.ts",
"!apps/cli/tests/helpers/migration-live.ts",
"!apps/cli/tests/helpers/notebooks.ts",
"!apps/cli/tests/helpers/postgres-config-live.ts",
"!apps/cli/tests/helpers/secrets-live.ts",
"!apps/cli/tests/helpers/storage-live.ts",
"!apps/cli/src/command-internal/agent-mode.*",
"!apps/cli/src/command-internal/api-*",
"!apps/cli/src/command-internal/bitbucket-pipeline.ts",
"!apps/cli/src/command-internal/branch-ref.*",
"!apps/cli/src/command-internal/branch-target.*",
"!apps/cli/src/command-internal/bundled-postgres-client.*",
"!apps/cli/src/command-internal/colors.*",
"!apps/cli/src/command-internal/config-pull-run.*",
"!apps/cli/src/command-internal/config-validate.*",
"!apps/cli/src/command-internal/connect-errors.*",
"!apps/cli/src/command-internal/container-cli.*",
"!apps/cli/src/command-internal/db-*.ts",
"!apps/cli/src/command-internal/db-bootstrap/**",
"!apps/cli/src/command-internal/debug-logger.*",
"!apps/cli/src/command-internal/detect-content-type.*",
"!apps/cli/src/command-internal/diff-engine.*",
"!apps/cli/src/command-internal/docker-*.ts",
"!apps/cli/src/command-internal/dotenv.*",
"!apps/cli/src/command-internal/drop-objects.ts",
"!apps/cli/src/command-internal/edge-runtime-*.ts",
"!apps/cli/src/command-internal/ensure-login.*",
"!apps/cli/src/command-internal/error-message.ts",
"!apps/cli/src/command-internal/experimental-feature.ts",
"!apps/cli/src/command-internal/experimental-gate.*",
"!apps/cli/src/command-internal/fail-on.*",
"!apps/cli/src/command-internal/get-api-keys.*",
"!apps/cli/src/command-internal/get-tenant-api-keys.*",
"!apps/cli/src/command-internal/git-status.*",
"!apps/cli/src/command-internal/glob.*",
"!apps/cli/src/command-internal/global-flags.*",
"!apps/cli/src/command-internal/go-duration.*",
"!apps/cli/src/command-internal/go-float.*",
"!apps/cli/src/command-internal/go-json.*",
"!apps/cli/src/command-internal/go-jwt.*",
"!apps/cli/src/command-internal/go-output-flag.*",
"!apps/cli/src/command-internal/go-output.encoders.*",
"!apps/cli/src/command-internal/go-quote.ts",
"!apps/cli/src/command-internal/go-struct-output.*",
"!apps/cli/src/command-internal/hostname.*",
"!apps/cli/src/command-internal/http-*",
"!apps/cli/src/command-internal/identity-stitch.*",
"!apps/cli/src/command-internal/kong-*",
"!apps/cli/src/command-internal/link-services-core.*",
"!apps/cli/src/command-internal/linked-state.*",
"!apps/cli/src/command-internal/local-project-context.*",
"!apps/cli/src/command-internal/login-*",
"!apps/cli/src/command-internal/make-dir.*",
"!apps/cli/src/command-internal/management-api-runtime.*",
"!apps/cli/src/command-internal/migrate-and-seed.*",
"!apps/cli/src/command-internal/migration*",
"!apps/cli/src/command-internal/param-introspection.*",
"!apps/cli/src/command-internal/parent-project-ref.*",
"!apps/cli/src/command-internal/parse-uint.*",
"!apps/cli/src/command-internal/password-requirements.ts",
"!apps/cli/src/command-internal/path-match.*",
"!apps/cli/src/command-internal/pflag-reconcile.*",
"!apps/cli/src/command-internal/pg-dump.*",
"!apps/cli/src/command-internal/pg-net-guidance.*",
"!apps/cli/src/command-internal/pgdelta*",
"!apps/cli/src/command-internal/pgpass.*",
"!apps/cli/src/command-internal/pgservicefile.*",
"!apps/cli/src/command-internal/pooler-fallback.*",
"!apps/cli/src/command-internal/postgres-client.run.*",
"!apps/cli/src/command-internal/postgres-url.*",
"!apps/cli/src/command-internal/profile-load.*",
"!apps/cli/src/command-internal/profile.*",
"!apps/cli/src/command-internal/project-create-core.*",
"!apps/cli/src/command-internal/project-environment.*",
"!apps/cli/src/command-internal/project-target.*",
"!apps/cli/src/command-internal/prompt-yes-no.*",
"!apps/cli/src/command-internal/raw-http.*",
"!apps/cli/src/command-internal/ref-patterns.*",
"!apps/cli/src/command-internal/region.format.*",
"!apps/cli/src/command-internal/resolve-token.*",
"!apps/cli/src/command-internal/rune-width.*",
"!apps/cli/src/command-internal/schema-flags.*",
"!apps/cli/src/command-internal/seed*",
"!apps/cli/src/command-internal/service-catalog.*",
"!apps/cli/src/command-internal/service-version-overrides.*",
"!apps/cli/src/command-internal/shell-quote.ts",
"!apps/cli/src/command-internal/size-units.ts",
"!apps/cli/src/command-internal/sql-files-glob.*",
"!apps/cli/src/command-internal/sql-split.*",
"!apps/cli/src/command-internal/stack-*",
"!apps/cli/src/command-internal/start-*",
"!apps/cli/src/command-internal/status-errors.ts",
"!apps/cli/src/command-internal/status-pretty.*",
"!apps/cli/src/command-internal/status-values.*",
"!apps/cli/src/command-internal/storage-*.ts",
"!apps/cli/src/command-internal/string-slice-flag.*",
"!apps/cli/src/command-internal/temp-paths.*",
"!apps/cli/src/command-internal/tenant-*",
"!apps/cli/src/command-internal/test-db.*",
"!apps/cli/src/command-internal/timestamp.format.*",
"!apps/cli/src/command-internal/upgrade-notice.*",
"!apps/cli/src/command-internal/upgrade-suggest.*",
"!apps/cli/src/command-internal/vault-decrypt.*",
"!apps/cli/src/command-internal/vault.*",
"!apps/cli/src/command-internal/viper-env.*",
"!apps/cli/src/command-internal/workdir-project.*",
"!apps/cli/src/command-internal/workdir-search.ts",
"!apps/cli/src/command-internal/workdir-validation.ts",
"apps/cli/src/command-internal/config-validate.parity.unit.test.ts",
"apps/cli/src/command-internal/config-validate.ts",
"apps/cli/src/command-internal/config-validate.unit.test.ts",
"apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts",
"apps/cli/src/command-internal/db-bootstrap/local-db-running.ts"
"!apps/cli/tests/helpers/storage-live.ts"
]
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,54 @@
import * as BunPath from "@effect/platform-bun/BunPath";
import { Effect, Path } from "effect";
import { describe, expect, it, vi } from "vitest";
import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
import { describe, expect, it } from "vitest";

import { resolveEmailTemplateContentPath } from "./config-validate.ts";

/**
* Exercises `canonicalPathForContainment`'s ancestor walk-up with a 5,000-component missing
* path — deeper than any real filesystem allows (`PATH_MAX` caps a real OS at a few thousand
* components), so `node:fs` is mocked here to drive the loop's own iteration count directly.
* Isolated into its own file because `vi.mock` is file-scoped and would break
* `config-validate.unit.test.ts`'s reliance on a real filesystem.
* components), so `FileSystem` is faked here to drive the loop's own iteration count directly.
*/
vi.mock("node:fs", () => ({
realpathSync: vi.fn((path: string) => {
if (path === FAKE_EXISTING_BASE) return FAKE_EXISTING_BASE;
throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), {
const missingEntry = (method: string, syscall: string, path: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method,
syscall,
pathOrDescriptor: path,
cause: Object.assign(new Error(`ENOENT: no such file or directory, ${syscall} '${path}'`), {
code: "ENOENT",
});
}),
lstatSync: vi.fn(() => undefined),
readlinkSync: vi.fn(() => {
throw new Error("readlinkSync should never be reached — no path in this fixture is a symlink");
}),
statSync: vi.fn(() => {
throw new Error("statSync should never be reached by the template content_path branch");
}),
}));
}),
});

const FAKE_EXISTING_BASE = "/fake/project-root";
const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer));
const fakeFileSystem = FileSystem.layerNoop({
realPath: (path) =>
path === FAKE_EXISTING_BASE
? Effect.succeed(FAKE_EXISTING_BASE)
: Effect.fail(missingEntry("realPath", "realpath", path)),
/* The `lstat` stand-in: no path in this fixture exists, so none is a symlink. */
readLink: (path) => Effect.fail(missingEntry("readLink", "readlink", path)),
stat: () =>
Effect.die(new Error("`stat` should never be reached by the template content_path branch")),
});

describe("canonicalPathForContainment (via resolveEmailTemplateContentPath)", () => {
it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () =>
import("./config-validate.ts").then(({ resolveEmailTemplateContentPath }) => {
const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`);
const contentPath = `${missingSegments.join("/")}/invite.html`;
it("resolves a 5,000-component-deep missing content_path without blowing the call stack", () => {
const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`);
const contentPath = `${missingSegments.join("/")}/invite.html`;

const resolved = resolveEmailTemplateContentPath({
const resolved = Effect.runSync(
resolveEmailTemplateContentPath({
section: "template",
name: "invite",
contentPath,
contentPresent: false,
base: FAKE_EXISTING_BASE,
});
}).pipe(Effect.provide(Layer.mergeAll(fakeFileSystem, BunPath.layer))),
);

expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath));
}));
expect(resolved).toBe(path.join(FAKE_EXISTING_BASE, contentPath));
});
});
84 changes: 49 additions & 35 deletions apps/cli/src/command-internal/config-validate.parity.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { unusedStackServices } from "../../tests/helpers/unused-stack.ts";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { CliConfigSchema, type CliConfig } from "@supabase/config";
import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect";

import { withEnvVar, mockCommandSettings } from "../../tests/helpers/command-mocks.ts";
import { withConfigEnv, mockCommandSettings } from "../../tests/helpers/command-mocks.ts";
import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts";
import { readDbToml } from "./db-config.toml-read.ts";
import { resolveStorageCredentials } from "./storage-credentials.ts";
Expand All @@ -28,12 +25,21 @@ import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts";
* the `baseConfig`/`WORKDIR` pattern from `local-config-values.unit.test.ts`.
*/

function withConfig(content: string) {
const dir = mkdtempSync(join(tmpdir(), "config-validate-parity-"));
mkdirSync(join(dir, "supabase"), { recursive: true });
writeFileSync(join(dir, "supabase", "config.toml"), content);
const withConfig = Effect.fnUntraced(function* (content: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectory({ prefix: "config-validate-parity-" });
yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true });
yield* fs.writeFileString(path.join(dir, "supabase", "config.toml"), content);
return dir;
}
});

const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
const removeDir = (dir: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.remove(dir, { recursive: true, force: true });
});

const readD = (workdir: string) =>
Effect.gen(function* () {
Expand All @@ -45,14 +51,14 @@ const readD = (workdir: string) =>
/** Drives D's real pipeline and asserts the failure message contains `message`. */
function failsWithD(tomlLines: ReadonlyArray<string>, message: string) {
return Effect.gen(function* () {
const dir = withConfig(tomlLines.join("\n"));
const dir = yield* withConfig(tomlLines.join("\n"));
const exit = yield* readD(dir).pipe(Effect.exit);
expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain(message);
expect(yield* encodeJson(exit.cause)).toContain(message);
}
rmSync(dir, { recursive: true, force: true });
});
yield* removeDir(dir);
}).pipe(Effect.provide(BunServices.layer));
}

const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema);
Expand All @@ -63,16 +69,19 @@ function baseConfig(overrides: Record<string, unknown> = {}): CliConfig {
}

/** Drives L's real pipeline and asserts the failure message contains `message`. */
function failsWithL(
const failsWithL = Effect.fnUntraced(function* (
overrides: Record<string, unknown>,
message: string,
document?: Readonly<Record<string, unknown>>,
) {
const config = baseConfig(overrides);
expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document)).toThrow(
message,
const error = yield* Effect.flip(
resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document).pipe(
Effect.provide(BunServices.layer),
),
);
}
expect(error.message).toContain(message);
});

interface ParityScenario {
readonly name: string;
Expand Down Expand Up @@ -303,7 +312,7 @@ describe("validateResolvedConfig cross-caller parity (D vs L)", () => {
it.effect(`${scenario.name}: D and L fail with the same message`, () =>
Effect.gen(function* () {
yield* failsWithD(scenario.toml, scenario.message);
failsWithL(scenario.overrides, scenario.message, scenario.document);
yield* failsWithL(scenario.overrides, scenario.message, scenario.document);
}),
);
}
Expand All @@ -317,7 +326,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)",
/** Drives S's real pipeline (`resolveStorageCredentials`, local branch) to failure. */
const failsWithS = (config: CliConfig, message: string) =>
Effect.gen(function* () {
const dir = withConfig("");
const dir = yield* withConfig("");
const exit = yield* resolveStorageCredentials({ projectRef: "", config }).pipe(
Effect.provide(
Layer.mergeAll(
Expand All @@ -336,30 +345,35 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)",
);
expect(Exit.isFailure(exit), `S: expected failure containing: ${message}`).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain(message);
expect(yield* encodeJson(exit.cause)).toContain(message);
}
rmSync(dir, { recursive: true, force: true });
});
yield* removeDir(dir);
}).pipe(Effect.provide(BunServices.layer));

// Ambient SUPABASE_API_* / SUPABASE_AUTH_* values would override the config
// under test, so pin every participating key to unset for the duration of
// each scenario.
const isolated = <A, E, R>(body: Effect.Effect<A, E, R>) =>
[
"SUPABASE_API_PORT",
"SUPABASE_API_ENABLED",
"SUPABASE_API_TLS_ENABLED",
"SUPABASE_API_TLS_CERT_PATH",
"SUPABASE_API_TLS_KEY_PATH",
"SUPABASE_AUTH_JWT_SECRET",
"SUPABASE_AUTH_SERVICE_ROLE_KEY",
].reduce((inner, name) => withEnvVar(name, undefined, inner), body);
withConfigEnv(
Object.fromEntries(
[
"SUPABASE_API_PORT",
"SUPABASE_API_ENABLED",
"SUPABASE_API_TLS_ENABLED",
"SUPABASE_API_TLS_CERT_PATH",
"SUPABASE_API_TLS_KEY_PATH",
"SUPABASE_AUTH_JWT_SECRET",
"SUPABASE_AUTH_SERVICE_ROLE_KEY",
].map((name) => [name, ""]),
),
body,
);

it.effect("api.port = 0 with the API enabled: S and L fail with the same message", () =>
isolated(
Effect.gen(function* () {
const message = "Missing required field in config: api.port";
failsWithL({ api: { port: 0 } }, message);
yield* failsWithL({ api: { port: 0 } }, message);
yield* failsWithS(baseConfig({ api: { port: 0 } }), message);
}),
),
Expand All @@ -369,7 +383,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)",
isolated(
Effect.gen(function* () {
const message = "Missing required field in config: api.tls.key_path";
failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message);
yield* failsWithL({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }, message);
yield* failsWithS(
baseConfig({ api: { tls: { enabled: true, cert_path: "kong.crt" } } }),
message,
Expand All @@ -382,7 +396,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)",
isolated(
Effect.gen(function* () {
const message = "Invalid config for auth.jwt_secret. Must be at least 16 characters";
failsWithL({ auth: { jwt_secret: "short" } }, message);
yield* failsWithL({ auth: { jwt_secret: "short" } }, message);
yield* failsWithS(baseConfig({ auth: { jwt_secret: "short" } }), message);
}),
),
Expand All @@ -395,7 +409,7 @@ describe("shared api + auth validation branches, cross-caller parity (S vs L)",
Effect.gen(function* () {
const message = "failed to parse config";
const auth = { service_role_key: "encrypted:not-a-real-ciphertext" };
failsWithL({ auth }, message);
yield* failsWithL({ auth }, message);
yield* failsWithS(baseConfig({ auth }), message);
}),
),
Expand Down
Loading
Loading