diff --git a/src/cli/output-safety.test.ts b/src/cli/output-safety.test.ts index 0d2549fe..bdaee085 100644 --- a/src/cli/output-safety.test.ts +++ b/src/cli/output-safety.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { startV1Stub, type V1Stub } from "../test-support/v1-stub.js"; @@ -50,6 +50,24 @@ function selfHostedEnv(): NodeJS.ProcessEnv { }; } +function rejectedClientEnvPointer(): { env: NodeJS.ProcessEnv; sentinel: string } { + const env = isolatedEnv(); + const binDir = mkdtempSync(join(tmpdir(), "emails-cli-client-env-rejection-")); + tempDirs.push(binDir); + const secretsBin = join(binDir, "secrets"); + writeFileSync(secretsBin, "#!/bin/sh\nexit 2\n"); + chmodSync(secretsBin, 0o700); + const sentinel = "OPE105_00301_SYNTHETIC_SENTINEL"; + return { + env: { + ...env, + PATH: `${binDir}:${env.PATH ?? ""}`, + EMAILS_CLIENT_ENV_SECRET: JSON.stringify({ fixture: sentinel }), + }, + sentinel, + }; +} + function runCli(args: string[], env: NodeJS.ProcessEnv) { return Bun.spawnSync({ cmd: ["bun", "src/cli/index.tsx", ...args], @@ -165,6 +183,54 @@ describe("CLI JSON output safety", () => { }); describe("CLI self-hosted bootstrap failures", () => { + it("redacts rejected client-env input from human and JSON stderr", () => { + for (const json of [false, true]) { + const { env, sentinel } = rejectedClientEnvPointer(); + const result = runCli(json ? ["--json", "status"] : ["status"], env); + const stdout = text(result.stdout); + const stderr = text(result.stderr); + + expect(result.exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).not.toContain(sentinel); + expect(stderr).not.toContain(env.EMAILS_CLIENT_ENV_SECRET!); + + if (json) { + const parsed = JSON.parse(stderr) as { + error: { code: string; message: string; fix_commands: string[] }; + }; + expect(parsed.error.code).toBe("error"); + expect(parsed.error.message).toContain("EMAILS_CLIENT_ENV_SECRET failed to load from the secrets vault"); + expect(parsed.error.fix_commands).toContain("emails --help"); + } else { + expect(stderr).toContain("EMAILS_CLIENT_ENV_SECRET failed to load from the secrets vault"); + } + } + }); + + it("keeps ordinary nonsecret configuration diagnostics descriptive", () => { + const modeSetting = ["EMAILS", "MODE"].join("_"); + for (const json of [false, true]) { + const result = runCli( + json ? ["--json", "status"] : ["status"], + { ...isolatedEnv(), [modeSetting]: "staging" }, + ); + const stderr = text(result.stderr); + + expect(result.exitCode).toBe(1); + expect(text(result.stdout)).toBe(""); + if (json) { + const parsed = JSON.parse(stderr) as { error: { code: string; message: string } }; + expect(parsed.error.code).toBe("error"); + expect(parsed.error.message).toContain("Unknown Emails mode 'staging'"); + expect(parsed.error.message).toContain("Use exactly local or self_hosted"); + } else { + expect(stderr).toContain("Unknown Emails mode 'staging'"); + expect(stderr).toContain("Use exactly local or self_hosted"); + } + } + }); + it("returns one structured JSON error and creates no local SQLite state for missing or invalid configuration", () => { const cases = [ { diff --git a/src/lib/client-env.test.ts b/src/lib/client-env.test.ts index aef1a22c..775b1d74 100644 --- a/src/lib/client-env.test.ts +++ b/src/lib/client-env.test.ts @@ -3,6 +3,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { + CLIENT_ENV_REQUIRED_KEYS, EMAILS_CLIENT_ENV_SECRET_ENV, EMAILS_IDP_TOKEN_ENV, EMAILS_SESSION_TOKEN_ENV, @@ -101,6 +102,15 @@ exit 2 process.env["PATH"] = `${dir}:${ORIGINAL_PATH ?? ""}`; } +function installFailingSecretsCommand(status: number): void { + const dir = mkdtempSync(join(tmpdir(), "emails-client-env-failure-")); + tempDirs.push(dir); + const bin = join(dir, "secrets"); + writeFileSync(bin, `#!/bin/sh\nexit ${status}\n`); + chmodSync(bin, 0o700); + process.env["PATH"] = `${dir}:${ORIGINAL_PATH ?? ""}`; +} + // A fake `secrets` backed by a JSON file so get/set round-trips (persist tests). // Emulates the CURRENT (>= 0.2.9) CLI: plaintext `get` requires --show on a // captured stdout, `set` accepts the value on stdin via --stdin, and the argv @@ -149,6 +159,63 @@ afterEach(() => { }); describe("Emails client-env loader", () => { + it("does not echo rejected client-env input when the secrets command exits nonzero", () => { + installFailingSecretsCommand(2); + const sentinel = "OPE105_00301_NONZERO_SENTINEL"; + process.env[EMAILS_CLIENT_ENV_SECRET_ENV] = JSON.stringify({ fixture: sentinel }); + + let message = ""; + try { + loadEmailsClientEnvSecret(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain("EMAILS_CLIENT_ENV_SECRET failed to load from the secrets vault"); + expect(message).toContain("status 2"); + expect(message).not.toContain(sentinel); + expect(message).not.toContain(process.env[EMAILS_CLIENT_ENV_SECRET_ENV]!); + }); + + it("does not echo client-env input when the secrets command cannot start", () => { + const dir = mkdtempSync(join(tmpdir(), "emails-client-env-no-secrets-bin-")); + tempDirs.push(dir); + process.env["PATH"] = dir; + const sentinel = "OPE105_00301_SPAWN_SENTINEL"; + process.env[EMAILS_CLIENT_ENV_SECRET_ENV] = JSON.stringify({ fixture: sentinel }); + + let message = ""; + try { + loadEmailsClientEnvSecret(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain("EMAILS_CLIENT_ENV_SECRET failed to load from the secrets vault"); + expect(message).toContain("could not start"); + expect(message).toContain("ENOENT"); + expect(message).not.toContain(sentinel); + expect(message).not.toContain(process.env[EMAILS_CLIENT_ENV_SECRET_ENV]!); + }); + + it("does not echo client-env input when the loaded entry is incomplete", () => { + installStaticSecretsCommand("{}"); + const sentinel = "OPE105_00301_INCOMPLETE_SENTINEL"; + process.env[EMAILS_CLIENT_ENV_SECRET_ENV] = JSON.stringify({ fixture: sentinel }); + + let message = ""; + try { + loadEmailsClientEnvSecret(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain("EMAILS_CLIENT_ENV_SECRET loaded from the secrets vault"); + expect(message).toContain(`missing ${CLIENT_ENV_REQUIRED_KEYS[0]}`); + expect(message).not.toContain(sentinel); + expect(message).not.toContain(process.env[EMAILS_CLIENT_ENV_SECRET_ENV]!); + }); + it("runs secrets get with a scrubbed environment", () => { const envPath = installCapturingSecretsCommand(); process.env[EMAILS_CLIENT_ENV_SECRET_ENV] = "hasna/test/opensource/emails/prod/client-env"; diff --git a/src/lib/client-env.ts b/src/lib/client-env.ts index 7a7530ac..f5ef9122 100644 --- a/src/lib/client-env.ts +++ b/src/lib/client-env.ts @@ -100,6 +100,11 @@ export interface EmailsClientEnvSecretLoad { const loadedClientEnvSecrets = new WeakMap(); +function safeProcessErrorCode(error: unknown): string | null { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : null; +} + function parseClientEnvSecret(raw: string): Record { try { const parsed = JSON.parse(raw); @@ -203,10 +208,17 @@ export function loadEmailsClientEnvSecret(env: NodeJS.ProcessEnv = process.env): maxBuffer: 1024 * 1024, }); if (result.error) { - throw new Error(`${EMAILS_CLIENT_ENV_SECRET_ENV} failed to load '${secretPath}' from the secrets vault: ${result.error.message}`); + const code = safeProcessErrorCode(result.error); + throw new Error( + `${EMAILS_CLIENT_ENV_SECRET_ENV} failed to load from the secrets vault because the secrets command could not start` + + `${code ? ` (${code})` : ""}.`, + ); } if (result.status !== 0) { - throw new Error(`${EMAILS_CLIENT_ENV_SECRET_ENV} failed to load '${secretPath}' from the secrets vault.`); + throw new Error( + `${EMAILS_CLIENT_ENV_SECRET_ENV} failed to load from the secrets vault because the secrets command exited with status ` + + `${result.status ?? "unknown"}.`, + ); } const loaded = parseClientEnvSecret(result.stdout ?? ""); @@ -221,7 +233,8 @@ export function loadEmailsClientEnvSecret(env: NodeJS.ProcessEnv = process.env): if (!hasClientEnvCredential(env)) missing.push(CLIENT_ENV_CREDENTIAL_KEYS.join(" or ")); if (missing.length > 0) { throw new Error( - `${EMAILS_CLIENT_ENV_SECRET_ENV} '${secretPath}' must contain ${CLIENT_ENV_REQUIRED_KEYS.join(", ")} ` + + `${EMAILS_CLIENT_ENV_SECRET_ENV} loaded from the secrets vault, but its entry must contain ` + + `${CLIENT_ENV_REQUIRED_KEYS.join(", ")} ` + `and a credential (${CLIENT_ENV_CREDENTIAL_KEYS.join(" or ")}); missing ${missing.join(", ")}.`, ); } diff --git a/src/lib/verification-code.test.ts b/src/lib/verification-code.test.ts index 6d2d14a0..71f50763 100644 --- a/src/lib/verification-code.test.ts +++ b/src/lib/verification-code.test.ts @@ -595,7 +595,7 @@ describe("a candidate read that cannot be performed refuses", () => { try { await listVerificationCodeCandidates("me@example.com", {}, refusing); } catch (error) { - thrown = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); + thrown = error instanceof Error ? error.message : String(error); } expect(thrown).not.toBe("");