diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index b045365..f31d6e6 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -13,7 +13,7 @@ import type { WhoamiResponse } from "@/features/management/model.ts"; import { formatWhoamiPrincipalLine } from "@/features/management/render.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; import { - assertProfileHasNoEnvCredentials, + assertNoEnvConfigMode, createEmptyProfile, deriveProfileName, profileExists, @@ -189,7 +189,7 @@ export function sameWhoamiContext(a: WhoamiResponse, b: WhoamiResponse): boolean } async function runLogin(args: LoginArgs, sink: OutputSink): Promise { - assertProfileHasNoEnvCredentials("altertable login"); + assertNoEnvConfigMode(); assertInteractiveLogin(); applyControlPlaneOverride(args); diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile.ts index f90449d..4de200a 100644 --- a/cli/src/commands/profile.ts +++ b/cli/src/commands/profile.ts @@ -29,11 +29,13 @@ import { } from "@/features/profile/render.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; import { - assertProfileHasNoEnvCredentials, + assertNoEnvConfigMode, createEmptyProfile, deleteProfile, + envConfigMode, getActiveProfileName, inspectProfile, + isFromEnvProfile, listProfiles, profileExists, renameProfile, @@ -55,12 +57,26 @@ function optionalArg(value: unknown): string | undefined { } function existingProfileName(name: string): string { - if (!profileExists(name)) { + // `_from_env` is a valid read target (rendered from env vars) only while env + // config is actually in effect; it has no stored directory, so otherwise the + // name resolves to nothing. + const resolvable = isFromEnvProfile(name) ? envConfigMode() : profileExists(name); + if (!resolvable) { throw new ConfigurationError(`Profile not found: ${name}`); } return name; } +// env/direnv export a selectable profile name; `_from_env` isn't one. +function requireStoredProfileForExport(name: string): string { + if (isFromEnvProfile(name)) { + throw new CliError( + "The active identity is configured through environment variables; there is no stored profile to export. Pass a profile name.", + ); + } + return existingProfileName(name); +} + function profileNameArgOrActive(args: Record): string { return args.name ? requireProfileName(args.name) : getActiveProfileName(); } @@ -130,6 +146,7 @@ const profileCreateCommand = defineLocalCommand({ }; }, async local(input, context) { + assertNoEnvConfigMode(); createEmptyProfile(input.name); await runProfileConfigure(input.configure, context.sink, input.name); setActiveProfile(input.name); @@ -192,7 +209,7 @@ function createProfileUseCommand(id: string, name: string, hidden = false) { return requireProfileName(args.name); }, local(profileName) { - assertProfileHasNoEnvCredentials("Switching profiles"); + assertNoEnvConfigMode(); setActiveProfile(profileName); return profileName; }, @@ -220,7 +237,7 @@ const profileSwitchCommand = defineLocalCommand({ return args.name ? requireProfileName(args.name) : undefined; }, async local(profileName) { - assertProfileHasNoEnvCredentials("Switching profiles"); + assertNoEnvConfigMode(); if (!profileName) { if (isJsonOutput(getCliContext()) || getCliContext().agent || process.stdin.isTTY !== true) { throw new CliError("Interactive profile switch requires a TTY. Pass a profile name."); @@ -265,7 +282,7 @@ const profileEnvCommand = defineValueCommand({ name: { type: "positional", description: "Profile name (default: active profile)" }, }, parse({ args }) { - return existingProfileName(profileNameArgOrActive(args)); + return requireStoredProfileForExport(profileNameArgOrActive(args)); }, value(profileName) { return profileName; @@ -289,7 +306,7 @@ const profileDirenvCommand = defineValueCommand({ name: { type: "positional", description: "Profile name (default: active profile)" }, }, parse({ args }) { - return existingProfileName(profileNameArgOrActive(args)); + return requireStoredProfileForExport(profileNameArgOrActive(args)); }, value(profileName) { return profileName; @@ -358,6 +375,7 @@ const profileRenameCommand = defineLocalCommand({ }; }, local(input) { + assertNoEnvConfigMode(); renameProfile(input.from, input.to); return input; }, @@ -387,6 +405,7 @@ const profileDeleteCommand = defineLocalCommand({ return requireProfileName(args.name); }, local(profileName) { + assertNoEnvConfigMode(); deleteProfile(profileName); return profileName; }, @@ -463,6 +482,7 @@ export const profileCommand = defineLocalCommand, void>( }, async local(args, context) { if (args.configure) { + assertNoEnvConfigMode(); await runProfileConfigure(args as ConfigureCommandArgs, context.sink); return; } diff --git a/cli/src/features/profile/model.ts b/cli/src/features/profile/model.ts index 0b76783..26c470e 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/features/profile/model.ts @@ -18,7 +18,9 @@ import { import { assertSafeProfileName, DEFAULT_PROFILE_NAME, + envConfigMode, FROM_ENV_PSEUDOPROFILE_NAME, + isFromEnvProfile, type ProfileConfigKey, ensureProfileExists, ensureProfilesLayout, @@ -37,8 +39,10 @@ export { DEFAULT_PROFILE_NAME, ensureProfileExists, ensureProfilesLayout, + envConfigMode, FROM_ENV_PSEUDOPROFILE_NAME, getActiveProfileName, + isFromEnvProfile, profileConfigFile, profileExists, profilesDir, @@ -279,7 +283,42 @@ function parseTimestampMs(raw: string | undefined): string | undefined { return new Date(timestamp).toISOString(); } +function envLakehouseAuthKind(): ProfileLakehouseAuth { + if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { + return "basic_token"; + } + if (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { + return "username_password"; + } + return "none"; +} + +// The `_from_env` identity has no stored directory, so build its inspection view +// from the environment variables in effect rather than reading disk. +function inspectFromEnvProfile(): ProfileInspect { + const management: ProfileManagementAuth = hasEnvManagementCredentials() ? "api_key" : "none"; + const lakehouse = envLakehouseAuthKind(); + return { + name: FROM_ENV_PSEUDOPROFILE_NAME, + active: true, + config_file: "environment variables", + organization: {}, + principal: {}, + environment: process.env.ALTERTABLE_ENV || undefined, + endpoints: { + data_plane: process.env.ALTERTABLE_API_BASE || undefined, + control_plane: process.env.ALTERTABLE_MANAGEMENT_API_BASE || undefined, + }, + auth: { management, lakehouse }, + status: management !== "none" || lakehouse !== "none" ? "configured" : "empty", + timestamps: {}, + }; +} + export function inspectProfile(name: string): ProfileInspect { + if (isFromEnvProfile(name)) { + return inspectFromEnvProfile(); + } assertSafeProfileName(name); ensureProfilesLayout(); if (!profileExists(name)) { @@ -501,32 +540,56 @@ function hasStoredLakehouseCredentials(profileName: string): boolean { ); } -export function hasCredentialsThroughEnv(): boolean { - return hasEnvManagementCredentials() || hasEnvLakehouseCredentials(); -} - /** * The profile identity currently in effect. Normally the active stored profile, - * but environment credentials override any stored profile for the whole process, - * so they surface as the reserved `_from_env` pseudo-profile. + * but env configuration isolates the identity to the reserved `_from_env` + * pseudo-profile (see `envConfigMode`). */ export function resolveActiveProfileName(): string { - return hasCredentialsThroughEnv() - ? FROM_ENV_PSEUDOPROFILE_NAME - : resolveWorkingProfile(getCliContext().profile); + return resolveWorkingProfile(getCliContext().profile); +} + +const ENV_CONFIG_VARS: ReadonlyArray<{ name: string; secret: boolean }> = [ + { name: "ALTERTABLE_API_KEY", secret: true }, + { name: "ALTERTABLE_BASIC_AUTH_TOKEN", secret: true }, + { name: "ALTERTABLE_LAKEHOUSE_USERNAME", secret: false }, + { name: "ALTERTABLE_LAKEHOUSE_PASSWORD", secret: true }, + { name: "ALTERTABLE_ENV", secret: false }, + { name: "ALTERTABLE_API_BASE", secret: false }, + { name: "ALTERTABLE_MANAGEMENT_API_BASE", secret: false }, +]; + +/** The profile-configuring env vars currently set, with secrets masked. */ +export function configuredEnvConfig(): Array<{ name: string; display: string }> { + return ENV_CONFIG_VARS.flatMap(({ name, secret }) => { + const value = process.env[name]; + if (!value) { + return []; + } + return [{ name, display: secret ? "set (hidden)" : value }]; + }); } /** - * Guard for commands that mutate stored-profile state (login, switching). While - * credentials come from the environment there is no stored profile to act on — - * the env vars pin the identity and would override any change — so refuse. + * Guard for commands that mutate stored-profile state (login, switching, + * create/delete/rename, configure). While the CLI is configured through + * environment variables the active identity is the synthetic `_from_env` + * profile — there is no stored profile to act on and the env vars would override + * any change — so refuse and show what's configured. */ -export function assertProfileHasNoEnvCredentials(action: string): void { - if (resolveActiveProfileName() === FROM_ENV_PSEUDOPROFILE_NAME) { - throw new ConfigurationError( - `${action} is disabled while credentials come from the environment. Unset ALTERTABLE_API_KEY / ALTERTABLE_BASIC_AUTH_TOKEN / ALTERTABLE_LAKEHOUSE_USERNAME / ALTERTABLE_LAKEHOUSE_PASSWORD to manage stored profiles.`, - ); +export function assertNoEnvConfigMode(): void { + if (!envConfigMode()) { + return; } + const lines = configuredEnvConfig().map(({ name, display }) => ` ${name.padEnd(32)} ${display}`); + throw new ConfigurationError( + [ + "Profile management commands aren't available when configuring through environment variables.", + "", + "Currently configured:", + ...lines, + ].join("\n"), + ); } export function configureCredentialStatus(profileName: string): ConfigureCredentialStatus { diff --git a/cli/src/features/profile/views.ts b/cli/src/features/profile/views.ts index 50c6c00..ccdef2d 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/features/profile/views.ts @@ -31,6 +31,7 @@ import type { ProfileInspect, ProfileSummary, } from "@/features/profile/model.ts"; +import { isFromEnvProfile } from "@/features/profile/model.ts"; import type { ShellExportView } from "@/ui/shell/model.ts"; const ACTIVE_PROFILE_MARK = "✓"; @@ -38,9 +39,16 @@ const INACTIVE_PROFILE_MARK = " "; const PROFILE_NAME_HEADER = `${INACTIVE_PROFILE_MARK} NAME`; function profileInspectRows(profile: ProfileInspect): DisplayRow[] { + // The env pseudo-profile has no stored name/status; a heading line replaces + // them (see buildProfileInspectView). + const identityRows: DisplayRow[] = isFromEnvProfile(profile.name) + ? [] + : [ + { label: "Profile", value: `${profile.name}${profile.active ? " (active)" : ""}` }, + { label: "Status", value: profile.status }, + ]; return [ - { label: "Profile", value: `${profile.name}${profile.active ? " (active)" : ""}` }, - { label: "Status", value: profile.status }, + ...identityRows, { label: "Principal", value: formatProfilePrincipal(profile) }, { label: "Organization", value: profile.organization.slug ?? "not set" }, { label: "Environment", value: profile.environment ?? "not set" }, @@ -66,6 +74,14 @@ function profileInspectRows(profile: ProfileInspect): DisplayRow[] { } export function buildProfileInspectView(profile: ProfileInspect): DisplayDocument { + if (isFromEnvProfile(profile.name)) { + return document( + section( + text([`${TERMINAL_INDENT}Configuration set from environment variables`]), + rows(profileInspectRows(profile)), + ), + ); + } return document(section(rows(profileInspectRows(profile)))); } diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index 6496755..1147b17 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -11,6 +11,26 @@ export const FROM_ENV_PSEUDOPROFILE_NAME = "_from_env"; const RESERVED_PROFILE_NAMES = new Set([FROM_ENV_PSEUDOPROFILE_NAME]); +export function isFromEnvProfile(name: string): boolean { + return name === FROM_ENV_PSEUDOPROFILE_NAME; +} + +// True when the CLI is configured through environment variables. Any +// profile-configuring var (credentials, endpoints, or environment) isolates the +// active identity to the synthetic `_from_env` profile so no stored profile +// value leaks in. Operational vars (ALTERTABLE_PROFILE, ALTERTABLE_CONFIG_HOME, +// secret backend, OAuth, mock/log) are deliberately excluded. +export function envConfigMode(): boolean { + return Boolean( + process.env.ALTERTABLE_API_KEY || + process.env.ALTERTABLE_BASIC_AUTH_TOKEN || + (process.env.ALTERTABLE_LAKEHOUSE_USERNAME && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) || + process.env.ALTERTABLE_ENV || + process.env.ALTERTABLE_API_BASE || + process.env.ALTERTABLE_MANAGEMENT_API_BASE, + ); +} + export const PROFILE_CONFIG_KEYS = [ "user", "api_key_env", @@ -79,6 +99,10 @@ export function profileConfigFile(name: string): string { } export function profileExists(name: string): boolean { + // The env pseudo-profile has no directory; never a real stored profile. + if (isFromEnvProfile(name)) { + return false; + } return existsSync(profileDir(name)); } @@ -92,6 +116,9 @@ export function ensureProfilesLayout(): void { } export function getActiveProfileName(): string { + if (envConfigMode()) { + return FROM_ENV_PSEUDOPROFILE_NAME; + } ensureProfilesLayout(); const active = kvGet(configFile(), "active_profile"); if (active.length > 0 && profileExists(active)) { @@ -110,6 +137,12 @@ export function setActiveProfile(name: string): void { } export function resolveWorkingProfile(override?: string): string { + // Env configuration isolates the active identity; it wins even over an + // explicit --profile / ALTERTABLE_PROFILE so nothing stored leaks in. + if (envConfigMode()) { + return FROM_ENV_PSEUDOPROFILE_NAME; + } + ensureProfilesLayout(); const explicit = override ?? process.env.ALTERTABLE_PROFILE ?? ""; @@ -137,6 +170,10 @@ export function listProfileNames(): string[] { } export function ensureProfileExists(name: string): void { + // The env pseudo-profile is never materialized on disk. + if (isFromEnvProfile(name)) { + return; + } assertSafeProfileName(name); ensureProfilesLayout(); if (!profileExists(name)) { @@ -145,10 +182,20 @@ export function ensureProfileExists(name: string): void { } export function configGet(key: string, profileName: string): string { + // Env config never reads stored values — the whole point of the isolation. + if (isFromEnvProfile(profileName)) { + return ""; + } return kvGet(profileConfigFile(profileName), key); } export function configSet(key: string, value: string, profileName: string): void { + // The env pseudo-profile has no directory. Profile-mutating commands are + // refused in env mode; the only writes that reach here are internal caches + // (e.g. lakehouse credential expiry), which are simply ephemeral — drop them. + if (isFromEnvProfile(profileName)) { + return; + } assertSafeProfileName(profileName); mkdirSync(profileDir(profileName), { recursive: true }); kvSet(profileConfigFile(profileName), key, value); @@ -161,6 +208,9 @@ export function readProfileConfigRecord( ProfileConfigKey, string | undefined >; + if (isFromEnvProfile(name)) { + return config; + } for (const key of PROFILE_CONFIG_KEYS) { config[key] = kvGet(profileConfigFile(name), key) || undefined; } diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 70bfb00..951f37c 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { credentialsFile, kvGet, kvSet, kvUnset } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { logWarn } from "@/lib/log.ts"; -import { assertSafeProfileName } from "@/lib/profile-store.ts"; +import { assertSafeProfileName, isFromEnvProfile } from "@/lib/profile-store.ts"; type SecretBackend = "macos" | "file"; @@ -118,6 +118,12 @@ export function secretSet( profileName: string, options?: SecretSetOptions, ): void { + // Env config isolates to `_from_env`, which has no store. Profile-mutating + // commands are refused in env mode; internal caches (e.g. a provisioned + // lakehouse token) are used in-process only — dropping them is correct. + if (isFromEnvProfile(profileName)) { + return; + } const storageAccount = resolveSecretKey(account, profileName); const backend = secretBackend(); if (backend === "macos" && !options?.fromArgv) { @@ -134,6 +140,10 @@ export function secretSet( } export function secretGet(key: string, profileName: string): string { + // The env pseudo-profile has no stored secrets; auth reads env vars directly. + if (isFromEnvProfile(profileName)) { + return ""; + } const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { @@ -156,6 +166,9 @@ export function secretGet(key: string, profileName: string): string { } export function secretExists(key: string, profileName: string): boolean { + if (isFromEnvProfile(profileName)) { + return false; + } const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { @@ -176,6 +189,9 @@ export function secretExists(key: string, profileName: string): boolean { } export function secretDelete(key: string, profileName: string): void { + if (isFromEnvProfile(profileName)) { + return; + } const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { diff --git a/cli/tests/lakehouse-provision.test.ts b/cli/tests/lakehouse-provision.test.ts index dd52937..5b061a8 100644 --- a/cli/tests/lakehouse-provision.test.ts +++ b/cli/tests/lakehouse-provision.test.ts @@ -30,11 +30,15 @@ beforeEach(() => { process.env.ALTERTABLE_SECRET_BACKEND = "file"; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; process.env.ALTERTABLE_HTTP_LOG = logFile; - process.env.ALTERTABLE_API_BASE = "https://api.example.com"; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - process.env.ALTERTABLE_API_KEY = "atm_test"; - process.env.ALTERTABLE_ENV = "env-1"; setCliContext({ debug: false, json: false, agent: false }); + // Endpoints, environment, and management key live in the stored `default` + // profile rather than env vars: setting those vars would trigger env-config + // isolation (`_from_env`), which has no store to cache provisioned lakehouse + // credentials in. This exercises the stored-profile provisioning path. + configSet("api_base", "https://api.example.com", profileName); + configSet("management_api_base", "https://app.example.com", profileName); + configSet("api_key_env", "env-1", profileName); + secretSet("api-key", "atm_test", profileName); refreshCliRuntimeContext(getCliRuntime().context); }); @@ -44,10 +48,6 @@ afterEach(() => { delete process.env.ALTERTABLE_SECRET_BACKEND; delete process.env.ALTERTABLE_MOCK_HTTP_FILE; delete process.env.ALTERTABLE_HTTP_LOG; - delete process.env.ALTERTABLE_API_BASE; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_API_KEY; - delete process.env.ALTERTABLE_ENV; }); function writeMocks(credentialBody: string = CREDENTIAL_BODY): void { diff --git a/cli/tests/oauth-refresh.test.ts b/cli/tests/oauth-refresh.test.ts index f4b70fb..ad03d4e 100644 --- a/cli/tests/oauth-refresh.test.ts +++ b/cli/tests/oauth-refresh.test.ts @@ -27,7 +27,9 @@ beforeEach(() => { process.env.ALTERTABLE_CONFIG_HOME = testHome; process.env.ALTERTABLE_SECRET_BACKEND = "file"; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "http://localhost:13000"; + // No ALTERTABLE_MANAGEMENT_API_BASE: it would trigger env-config isolation + // (`_from_env`) and bypass the stored-profile OAuth session these tests set up. + // The mock intercepts by URL substring regardless of host. setCliContext({ debug: false, json: false, agent: false }); }); @@ -36,6 +38,8 @@ afterEach(() => { delete process.env.ALTERTABLE_CONFIG_HOME; delete process.env.ALTERTABLE_SECRET_BACKEND; delete process.env.ALTERTABLE_MOCK_HTTP_FILE; + // Individual tests may set this to exercise endpoint overrides; always clean up + // so it can't leak into later tests and trip env-config isolation. delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; delete process.env.ALTERTABLE_API_KEY; }); diff --git a/cli/tests/oauth.test.ts b/cli/tests/oauth.test.ts index 49d97f0..55ca7d2 100644 --- a/cli/tests/oauth.test.ts +++ b/cli/tests/oauth.test.ts @@ -162,10 +162,7 @@ import { sameWhoamiContext, storeLoginProfileMetadata, } from "@/commands/login.ts"; -import { - assertProfileHasNoEnvCredentials, - resolveActiveProfileName, -} from "@/features/profile/model.ts"; +import { assertNoEnvConfigMode, resolveActiveProfileName } from "@/features/profile/model.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { configureRunClear } from "@/lib/profile-configure-core.ts"; import { getOutputSink } from "@/lib/runtime.ts"; @@ -319,11 +316,11 @@ describe("login profile metadata", () => { // Environment credentials pin the identity, so stored-profile controls (login, // switching) are locked while they are set. - test("locks stored-profile controls while credentials come from the environment", () => { - expect(() => assertProfileHasNoEnvCredentials("altertable login")).not.toThrow(); + test("locks stored-profile controls while the CLI is configured through the environment", () => { + expect(() => assertNoEnvConfigMode()).not.toThrow(); process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(() => assertProfileHasNoEnvCredentials("altertable login")).toThrow(ConfigurationError); + expect(() => assertNoEnvConfigMode()).toThrow(ConfigurationError); }); test("_from_env is a reserved profile name that cannot be created", () => { diff --git a/cli/tests/profile-status.test.ts b/cli/tests/profile-status.test.ts index 8384ad7..5cbea8a 100644 --- a/cli/tests/profile-status.test.ts +++ b/cli/tests/profile-status.test.ts @@ -19,8 +19,9 @@ beforeEach(() => { process.env.ALTERTABLE_CONFIG_HOME = testHome; process.env.ALTERTABLE_SECRET_BACKEND = "file"; process.env.ALTERTABLE_MOCK_HTTP_FILE = mockFile; - process.env.ALTERTABLE_MANAGEMENT_API_BASE = "http://localhost:13000"; - process.env.ALTERTABLE_API_BASE = "http://localhost:15000"; + // No ALTERTABLE_API_BASE / ALTERTABLE_MANAGEMENT_API_BASE: those trigger + // env-config isolation (`_from_env`), which would ignore the stored profile + // this test configures. The mock intercepts by URL substring regardless of host. setCliContext({ debug: false, json: false, agent: false }); refreshCliRuntimeContext(getCliContext()); }); @@ -30,8 +31,6 @@ afterEach(() => { delete process.env.ALTERTABLE_CONFIG_HOME; delete process.env.ALTERTABLE_SECRET_BACKEND; delete process.env.ALTERTABLE_MOCK_HTTP_FILE; - delete process.env.ALTERTABLE_MANAGEMENT_API_BASE; - delete process.env.ALTERTABLE_API_BASE; }); describe("configureVerify", () => { diff --git a/tests/configure.test.ts b/tests/configure.test.ts index dafc096..7698e06 100644 --- a/tests/configure.test.ts +++ b/tests/configure.test.ts @@ -185,10 +185,16 @@ describe("altertable profile --configure", () => { expect((await workspace.runCommand("altertable profile --configure --user u --password p --data-plane-url http://127.0.0.1:1111")).exitCode).toBe(0); await workspace.setupHttpLog(); await workspace.setupMockHttp(queryMock); + // An env data-plane URL isolates to `_from_env`, so credentials must also come + // from the environment; the stored data-plane root is not consulted. expect( ( await workspace.runCommand('altertable query "SELECT 1"', { - env: { ALTERTABLE_API_BASE: "http://127.0.0.1:2222" }, + env: { + ALTERTABLE_API_BASE: "http://127.0.0.1:2222", + ALTERTABLE_LAKEHOUSE_USERNAME: "u", + ALTERTABLE_LAKEHOUSE_PASSWORD: "p", + }, }) ).exitCode, ).toBe(0); diff --git a/tests/integration.e2e.ts b/tests/integration.e2e.ts index 7164e1e..b5e97ee 100644 --- a/tests/integration.e2e.ts +++ b/tests/integration.e2e.ts @@ -55,7 +55,7 @@ describe("lakehouse integration flows", () => { result = await workspace.runCommand("altertable --agent profile show", { env: { ALTERTABLE_API_KEY: "atm_test" } }); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).profile.name).toBe("default"); + expect(JSON.parse(result.stdout).profile.name).toBe("_from_env"); const queryId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; const sessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; diff --git a/tests/management.test.ts b/tests/management.test.ts index 0e122f2..155ca7e 100644 --- a/tests/management.test.ts +++ b/tests/management.test.ts @@ -44,11 +44,20 @@ describe("management API user flows", () => { }); test("stored and trailing-slash management roots resolve to /rest/v1", async () => { - await workspace.configureStoredManagementCredential(); - await workspace.appendFile(workspace.defaultProfileConfig, "management_api_base=http://localhost:7\n"); + await workspace.resetConfig(); + // Store the control-plane root on the profile itself. Stored roots resolve + // only through the stored credential — an env API key would isolate to + // `_from_env` and ignore the stored management_api_base. + expect( + ( + await workspace.runCommand( + "altertable profile --configure --api-key atm_stored --env production --control-plane-url http://localhost:7", + ) + ).exitCode, + ).toBe(0); await workspace.setupHttpLog(); await workspace.setupMockHttp(whoamiMock()); - expect((await workspace.runCommand("altertable api GET /whoami", { env: { ALTERTABLE_API_KEY: "atm_env" } })).exitCode).toBe(0); + expect((await workspace.runCommand("altertable api GET /whoami")).exitCode).toBe(0); expect(await workspace.httpLogValue("URL")).toBe("http://localhost:7/rest/v1/whoami"); await workspace.setupHttpLog(); diff --git a/tests/profile.test.ts b/tests/profile.test.ts index f71b16e..065068f 100644 --- a/tests/profile.test.ts +++ b/tests/profile.test.ts @@ -73,26 +73,51 @@ describe("profile switching", () => { expect(result.stdout).not.toContain("globex_dev"); }); - // Environment credentials pin the identity to `_from_env`, so login and profile - // switching are disabled — they would only mutate stored state the env overrides. - test("environment credentials disable login and profile switching", async () => { + // `_from_env` names a real identity only while env config is in effect. With + // no env vars set it resolves to nothing, so reading it must 404 rather than + // render a fake empty view. + test("show and status reject _from_env when no env configuration is set", async () => { + const shown = await workspace.runCommand("altertable profile show --name _from_env"); + expect(shown.exitCode).not.toBe(0); + expect(shown.stderr).toContain("Profile not found: _from_env"); + + const status = await workspace.runCommand("altertable profile status --name _from_env"); + expect(status.exitCode).not.toBe(0); + expect(status.stderr).toContain("Profile not found: _from_env"); + }); + + // Env configuration pins the identity to `_from_env`, so profile-mutating + // commands are refused — they would only mutate stored state the env overrides. + // The refusal lists the currently configured env vars (secrets masked). + test("env configuration disables login and profile-mutating commands", async () => { const login = await workspace.runCommand("altertable login", { env: { ALTERTABLE_API_KEY: "atm_env" }, }); expect(login.exitCode).not.toBe(0); - expect(login.stderr).toContain("disabled while credentials come from the environment"); + expect(login.stderr).toContain( + "Profile management commands aren't available when configuring through environment variables", + ); + expect(login.stderr).toContain("ALTERTABLE_API_KEY"); + expect(login.stderr).toContain("set (hidden)"); const use = await workspace.runCommand("altertable profile use acme_staging", { env: { ALTERTABLE_API_KEY: "atm_env" }, }); expect(use.exitCode).not.toBe(0); - expect(use.stderr).toContain("disabled while credentials come from the environment"); + expect(use.stderr).toContain("aren't available when configuring through environment variables"); + + const created = await workspace.runCommand("altertable profile create globex --api-key atm_x --env dev", { + env: { ALTERTABLE_ENV: "staging" }, + }); + expect(created.exitCode).not.toBe(0); + expect(created.stderr).toContain("aren't available when configuring through environment variables"); + expect(created.stderr).toContain("ALTERTABLE_ENV"); const switched = await workspace.runCommand("altertable profile switch acme_staging", { env: { ALTERTABLE_LAKEHOUSE_USERNAME: "u", ALTERTABLE_LAKEHOUSE_PASSWORD: "p" }, }); expect(switched.exitCode).not.toBe(0); - expect(switched.stderr).toContain("disabled while credentials come from the environment"); + expect(switched.stderr).toContain("aren't available when configuring through environment variables"); }); }); diff --git a/tests/scripting.test.ts b/tests/scripting.test.ts index d19cf8a..6b98820 100644 --- a/tests/scripting.test.ts +++ b/tests/scripting.test.ts @@ -22,12 +22,14 @@ describe("scriptable exit codes and JSON errors", () => { }); }); + // The workspace is configured through env vars (ALTERTABLE_API_KEY/ENV), so the + // active identity is the reserved `_from_env` pseudo-profile. test("--json profile show exits 0 and prints structured success JSON", async () => { const result = await workspace.runCommand("altertable --json profile show"); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout).profile.name).toBe("default"); + expect(JSON.parse(result.stdout).profile.name).toBe("_from_env"); }); test.each([