stack/packages/cli/src/lib/auth.test.ts
BilalG1 59547ef4ec
Detect conflicting Hexclave and Stack env vars (#1604)
Summary: Detects conflicting non-empty HEXCLAVE_* and STACK_* values
across shared env helpers, dashboard public envs, generated SDK env
access, Docker scripts, CLI/docs/examples, and related tests.
Verification: pnpm test run packages/shared/src/utils/env.test.tsx
apps/dashboard/src/lib/env.test.tsx packages/cli/src/lib/auth.test.ts;
targeted lint/typecheck across touched workspaces; bash -n/node --check
for changed scripts; node
docker/local-emulator/generate-env-development.mjs --check.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Detects and blocks conflicting `HEXCLAVE_*` and `STACK_*` env vars
across the monorepo. Prefers `HEXCLAVE_*`, falls back to `STACK_*` when
empty, and fails fast when both are set to different values.

- **New Features**
- Added conflict-aware env resolvers used across apps, CLI, docs,
examples, and Docker (build/runtime).
- Validates critical vars (e.g., database connection, API/dashboard
URLs, emulator flags, tokens) and ignores post-build sentinel values.
- Prisma, Next.js, and Docker startup now error on mismatched values;
CLI enforces project ID/key conflicts; tests added.

- **Migration**
- If both names are set with different values, builds/tests/scripts will
error. Set only `HEXCLAVE_*` or make both equal.
- Update `.env`, CI secrets, and Docker envs to use `HEXCLAVE_*`. Keep
`STACK_*` only as a temporary fallback.

<sup>Written for commit 4d63fa3bad.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1604?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-06-16 10:57:59 -07:00

127 lines
5.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isRetryableFetchError, localEmulatorReadyTimeoutMs, resolveProjectId } from "./auth.js";
describe("isRetryableFetchError", () => {
it("retries TypeError (Node fetch wraps connection errors as TypeError)", () => {
expect(isRetryableFetchError(new TypeError("fetch failed"))).toBe(true);
});
it("retries AbortError and TimeoutError (per-request signal fired)", () => {
const abort = new Error("aborted");
abort.name = "AbortError";
expect(isRetryableFetchError(abort)).toBe(true);
const timeout = new Error("timed out");
timeout.name = "TimeoutError";
expect(isRetryableFetchError(timeout)).toBe(true);
});
it("retries ECONNREFUSED / ENOTFOUND / ETIMEDOUT / ECONNRESET messages", () => {
expect(isRetryableFetchError(new Error("connect ECONNREFUSED 127.0.0.1:1"))).toBe(true);
expect(isRetryableFetchError(new Error("getaddrinfo ENOTFOUND foo"))).toBe(true);
expect(isRetryableFetchError(new Error("ETIMEDOUT"))).toBe(true);
expect(isRetryableFetchError(new Error("read ECONNRESET"))).toBe(true);
});
it("retries non-Error throws (defensive: unknown shape, give it another go)", () => {
expect(isRetryableFetchError("string")).toBe(true);
expect(isRetryableFetchError(undefined)).toBe(true);
expect(isRetryableFetchError({ weird: true })).toBe(true);
});
it("does not retry generic Errors that aren't transport-shaped", () => {
expect(isRetryableFetchError(new Error("something else broke"))).toBe(false);
expect(isRetryableFetchError(new SyntaxError("bad json"))).toBe(false);
});
});
describe("localEmulatorReadyTimeoutMs", () => {
const SAVED = process.env.STACK_EMULATOR_READY_TIMEOUT_MS;
beforeEach(() => {
delete process.env.STACK_EMULATOR_READY_TIMEOUT_MS;
});
afterEach(() => {
if (SAVED === undefined) delete process.env.STACK_EMULATOR_READY_TIMEOUT_MS;
else process.env.STACK_EMULATOR_READY_TIMEOUT_MS = SAVED;
});
it("returns the default when the env var is unset", () => {
expect(localEmulatorReadyTimeoutMs()).toBe(10_000);
});
it("treats empty string as unset", () => {
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "";
expect(localEmulatorReadyTimeoutMs()).toBe(10_000);
});
it("parses a valid non-negative integer (including 0 for fail-fast)", () => {
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "0";
expect(localEmulatorReadyTimeoutMs()).toBe(0);
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "2500";
expect(localEmulatorReadyTimeoutMs()).toBe(2500);
});
it("rejects negative, non-integer, and non-numeric values", () => {
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "-1";
expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/);
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "1.5";
expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/);
process.env.STACK_EMULATOR_READY_TIMEOUT_MS = "abc";
expect(() => localEmulatorReadyTimeoutMs()).toThrow(/Invalid STACK_EMULATOR_READY_TIMEOUT_MS/);
});
});
describe("resolveProjectId", () => {
const SAVED = process.env.STACK_PROJECT_ID;
const SAVED_HEXCLAVE = process.env.HEXCLAVE_PROJECT_ID;
beforeEach(() => {
delete process.env.STACK_PROJECT_ID;
delete process.env.HEXCLAVE_PROJECT_ID;
});
afterEach(() => {
if (SAVED === undefined) delete process.env.STACK_PROJECT_ID;
else process.env.STACK_PROJECT_ID = SAVED;
if (SAVED_HEXCLAVE === undefined) delete process.env.HEXCLAVE_PROJECT_ID;
else process.env.HEXCLAVE_PROJECT_ID = SAVED_HEXCLAVE;
});
it("uses the --cloud-project-id option when provided", () => {
expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag");
});
it("falls back to the STACK_PROJECT_ID env var when the option is omitted", () => {
process.env.STACK_PROJECT_ID = "proj_from_env";
expect(resolveProjectId(undefined)).toBe("proj_from_env");
});
it("uses the HEXCLAVE_PROJECT_ID env var", () => {
process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env";
expect(resolveProjectId(undefined)).toBe("proj_from_hexclave_env");
});
it("throws when the Hexclave and Stack env vars disagree", () => {
process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env";
process.env.STACK_PROJECT_ID = "proj_from_stack_env";
expect(() => resolveProjectId(undefined)).toThrow(/HEXCLAVE_PROJECT_ID.*STACK_PROJECT_ID.*different values/);
});
it("prefers the option over the env var", () => {
process.env.STACK_PROJECT_ID = "proj_from_env";
expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag");
});
it("does not inspect conflicting env vars when the option is present", () => {
process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env";
process.env.STACK_PROJECT_ID = "proj_from_stack_env";
expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag");
});
it("treats an empty option string as absent and falls back to the env var", () => {
process.env.STACK_PROJECT_ID = "proj_from_env";
expect(resolveProjectId("")).toBe("proj_from_env");
});
it("throws a CliError with help text when neither is provided", () => {
expect(() => resolveProjectId(undefined)).toThrow(/HEXCLAVE_PROJECT_ID/);
});
});