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
68 changes: 67 additions & 1 deletion src/cli/output-safety.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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 = [
{
Expand Down
67 changes: 67 additions & 0 deletions src/lib/client-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down
19 changes: 16 additions & 3 deletions src/lib/client-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export interface EmailsClientEnvSecretLoad {

const loadedClientEnvSecrets = new WeakMap<NodeJS.ProcessEnv, string>();

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<string, string> {
try {
const parsed = JSON.parse(raw);
Expand Down Expand Up @@ -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 ?? "");
Expand All @@ -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(", ")}.`,
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/verification-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down
Loading