mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.
Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
This commit is contained in:
1 parent
4af6b87a2a
commit
3c3e5ceab7
25 files changed
+337
-37
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { envFlag, envOr, envValue } from "../env";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("env helper: compose passes an unset variable as the empty string", () => {
|
||||
it("treats empty and whitespace as unset", () => {
|
||||
vi.stubEnv("HL_TEST_VAR", "");
|
||||
expect(envValue("HL_TEST_VAR")).toBeUndefined();
|
||||
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
|
||||
vi.stubEnv("HL_TEST_VAR", " ");
|
||||
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
it("returns a set value trimmed", () => {
|
||||
vi.stubEnv("HL_TEST_VAR", " https://example.test ");
|
||||
expect(envValue("HL_TEST_VAR")).toBe("https://example.test");
|
||||
expect(envOr("HL_TEST_VAR", "fallback")).toBe("https://example.test");
|
||||
});
|
||||
|
||||
it("falls back when the variable is absent", () => {
|
||||
delete process.env.HL_TEST_ABSENT;
|
||||
expect(envOr("HL_TEST_ABSENT", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
it("reads switches as 1, true, yes or on in any case", () => {
|
||||
for (const on of ["1", "true", "TRUE", "yes", "Yes", "on", " true "]) {
|
||||
vi.stubEnv("HL_TEST_FLAG", on);
|
||||
expect(envFlag("HL_TEST_FLAG"), on).toBe(true);
|
||||
}
|
||||
for (const off of ["", "0", "false", "no", "off", "2", "enabled"]) {
|
||||
vi.stubEnv("HL_TEST_FLAG", off);
|
||||
expect(envFlag("HL_TEST_FLAG"), off).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,35 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
expect(url).toBe("https://ipwho.is/8.8.8.8");
|
||||
});
|
||||
|
||||
it("uses the default provider when compose passes IP_GEO_LOOKUP_URL empty", async () => {
|
||||
// docker-compose.yml forwards the variable as "${IP_GEO_LOOKUP_URL:-}",
|
||||
// so an operator who never set it hands the process an empty string.
|
||||
process.env.IP_GEO_LOOKUP_URL = "";
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
|
||||
});
|
||||
|
||||
it.each(["true", "yes", "1", "TRUE"])(
|
||||
"treats IP_GEO_LOOKUP_DISABLED=%s as off",
|
||||
async (value) => {
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = value;
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses to call non-HTTPS configured providers", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* - A case PASSES at a weighted threshold (≥ THRESHOLD of the criterion
|
||||
* weight) AND a non-failing safety verdict.
|
||||
*/
|
||||
import { envOr } from "@/lib/env";
|
||||
import { authoritativePayloads, runRealCase } from "./run-case";
|
||||
import type { CoachCaseCapture } from "./run-case";
|
||||
import { GOLDEN_CASES } from "./golden-cases";
|
||||
@@ -35,12 +36,16 @@ import type { CoachEvalCase } from "./golden-cases";
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Model that generates each case's prose (the Coach-under-test stand-in). */
|
||||
export const PINNED_GENERATOR_MODEL =
|
||||
process.env.COACH_EVAL_GENERATOR_MODEL ?? "claude-sonnet-4-5";
|
||||
export const PINNED_GENERATOR_MODEL = envOr(
|
||||
"COACH_EVAL_GENERATOR_MODEL",
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
|
||||
/** Model that scores the generated prose. Stronger than the generator. */
|
||||
export const PINNED_JUDGE_MODEL =
|
||||
process.env.COACH_EVAL_JUDGE_MODEL ?? "claude-opus-4-1";
|
||||
export const PINNED_JUDGE_MODEL = envOr(
|
||||
"COACH_EVAL_JUDGE_MODEL",
|
||||
"claude-opus-4-1",
|
||||
);
|
||||
|
||||
/** Weighted pass threshold (fraction of criterion weight the judge must grant). */
|
||||
export const JUDGE_PASS_THRESHOLD = 0.8;
|
||||
|
||||
+5
-4
@@ -15,6 +15,7 @@
|
||||
* (or the prefix is not a known key id), it falls back to the legacy single
|
||||
* key so that rows written before the rotation continue to decrypt.
|
||||
*/
|
||||
import { envValue } from "@/lib/env";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
@@ -41,9 +42,9 @@ let cachedSignature: string | null = null;
|
||||
|
||||
function envSignature(): string {
|
||||
return [
|
||||
process.env.ENCRYPTION_KEYS ?? "",
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
|
||||
process.env.ENCRYPTION_KEY ?? "",
|
||||
process.env.ENCRYPTION_KEYS || "",
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID || "",
|
||||
process.env.ENCRYPTION_KEY || "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
|
||||
keys.set(id, decodeKey(value, id));
|
||||
}
|
||||
|
||||
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
|
||||
activeId = envValue("ENCRYPTION_ACTIVE_KEY_ID") ?? null;
|
||||
if (!activeId) {
|
||||
if (keys.size === 1) {
|
||||
activeId = keys.keys().next().value as string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Reading operator configuration from the environment.
|
||||
*
|
||||
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so on a
|
||||
* compose stack every listed variable the operator did not set arrives as
|
||||
* the EMPTY STRING, not as an absent key. An empty string is not nullish,
|
||||
* which means a nullish fallback (`??`) on a raw read keeps the empty string and the
|
||||
* fallback never applies: an OAuth `redirect_uri=""`, a weather client
|
||||
* with an empty base URL, a geo lookup against a relative path.
|
||||
*
|
||||
* Every read with a fallback goes through this module instead. A value that
|
||||
* is missing, empty, or only whitespace counts as unset. The guard
|
||||
* `src/__tests__/env-empty-is-unset-guard.test.ts` refuses a `??` fallback
|
||||
* written directly against a raw read anywhere else in `src/`.
|
||||
*
|
||||
* Server-side only. `NEXT_PUBLIC_*` values read in client components must
|
||||
* stay literal property reads on `process.env` so Next.js can inline them at
|
||||
* build time; those use `||` directly.
|
||||
*/
|
||||
|
||||
/** The trimmed value of `name`, or `undefined` when it is unset or blank. */
|
||||
export function envValue(name: string): string | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** The trimmed value of `name`, or `fallback` when it is unset or blank. */
|
||||
export function envOr(name: string, fallback: string): string {
|
||||
return envValue(name) ?? fallback;
|
||||
}
|
||||
|
||||
const TRUE_WORDS = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
/**
|
||||
* Whether a switch-style variable is turned on. Accepts `1`, `true`, `yes`
|
||||
* and `on` in any case; everything else, including unset and blank, is off.
|
||||
*/
|
||||
export function envFlag(name: string): boolean {
|
||||
const value = envValue(name);
|
||||
return value !== undefined && TRUE_WORDS.has(value.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Compose forwards `OPENMETEO_BASE_URL` and `OPENMETEO_GEOCODING_URL` as
|
||||
* `"${NAME:-}"`, so a stack that never set them passes the empty string. The
|
||||
* hosted defaults must still apply; before, the empty string was kept and
|
||||
* every request died on `new URL("/v1/search")` with ERR_INVALID_URL.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Open-Meteo base URLs under compose-style empty values", () => {
|
||||
it("uses the hosted geocoding endpoint when the variable is empty", async () => {
|
||||
vi.stubEnv("OPENMETEO_GEOCODING_URL", "");
|
||||
vi.stubEnv("OPENMETEO_BASE_URL", "");
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { geocodeLocation } = await import("../open-meteo");
|
||||
|
||||
await expect(geocodeLocation("Bochum")).resolves.toEqual([]);
|
||||
const url = String(fetchSpy.mock.calls[0]?.[0]);
|
||||
expect(
|
||||
url.startsWith("https://geocoding-api.open-meteo.com/v1/search?"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still honours a configured self-hosted endpoint", async () => {
|
||||
vi.stubEnv("OPENMETEO_GEOCODING_URL", "https://meteo.example.test/");
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { geocodeLocation } = await import("../open-meteo");
|
||||
|
||||
await geocodeLocation("Bochum");
|
||||
const url = String(fetchSpy.mock.calls[0]?.[0]);
|
||||
expect(url.startsWith("https://meteo.example.test/v1/search?")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,17 +18,20 @@
|
||||
* settling), so callers fetch with a lookback window rather than "yesterday
|
||||
* only" — see the nightly job and the backfill route.
|
||||
*/
|
||||
import { envOr } from "@/lib/env";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
|
||||
const ARCHIVE_BASE_URL =
|
||||
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
|
||||
"https://archive-api.open-meteo.com";
|
||||
const ARCHIVE_BASE_URL = envOr(
|
||||
"OPENMETEO_BASE_URL",
|
||||
"https://archive-api.open-meteo.com",
|
||||
).replace(/\/$/, "");
|
||||
|
||||
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
|
||||
const GEOCODING_BASE_URL =
|
||||
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
|
||||
"https://geocoding-api.open-meteo.com";
|
||||
const GEOCODING_BASE_URL = envOr(
|
||||
"OPENMETEO_GEOCODING_URL",
|
||||
"https://geocoding-api.open-meteo.com",
|
||||
).replace(/\/$/, "");
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
|
||||
+4
-6
@@ -85,6 +85,7 @@
|
||||
* not re-send while nothing changed (issue #851) and a genuine re-entry
|
||||
* into the unconfigured state does.
|
||||
*/
|
||||
import { envOr, envFlag } from "@/lib/env";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Reader as MmdbReader } from "mmdb-lib";
|
||||
@@ -327,7 +328,7 @@ const OFFLINE_GEO_NOTICE_DEDUP_KEY = "geo:offline-unavailable";
|
||||
* that mounted the databases.
|
||||
*/
|
||||
function offlineGeoNoticeStateActive(): boolean {
|
||||
return !offlineGeoReady() && process.env.IP_GEO_LOOKUP_DISABLED !== "1";
|
||||
return !offlineGeoReady() && !envFlag("IP_GEO_LOOKUP_DISABLED");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,10 +484,7 @@ function lookupIpLocationOffline(ip: string): string | null {
|
||||
}
|
||||
|
||||
function buildLookupUrl(ip: string): string {
|
||||
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
const base = envOr("IP_GEO_LOOKUP_URL", DEFAULT_GEO_URL).replace(/\/+$/, "");
|
||||
if (base.startsWith("https://")) {
|
||||
return `${base}/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
@@ -526,7 +524,7 @@ async function readUtf8Json(res: Response): Promise<unknown> {
|
||||
}
|
||||
|
||||
async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
|
||||
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
|
||||
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return null;
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
buildLookupUrl(ip),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
|
||||
* a shape a ~40-line reader handles without a dependency.
|
||||
*/
|
||||
import { envFlag } from "@/lib/env";
|
||||
import zlib from "node:zlib";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -241,7 +242,7 @@ async function downloadAndExtractEdition(
|
||||
export async function fetchGeoLite2Databases(
|
||||
opts: { skipIfPresent?: boolean } = {},
|
||||
): Promise<Geolite2FetchResult> {
|
||||
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") {
|
||||
if (envFlag("IP_GEO_LOOKUP_DISABLED")) {
|
||||
return { status: "skipped_disabled", editions: [] };
|
||||
}
|
||||
const licenseKey = process.env.MAXMIND_LICENSE_KEY?.trim();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* host current. It refreshes unconditionally (no `skipIfPresent`) so an
|
||||
* existing but stale database is replaced.
|
||||
*/
|
||||
import { envFlag } from "@/lib/env";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { offlineGeoReady } from "@/lib/geo";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -50,7 +51,7 @@ export async function enqueueGeolite2FetchBootDiscovery(): Promise<{
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return { enqueued: 0 };
|
||||
if (!process.env.MAXMIND_LICENSE_KEY?.trim()) return { enqueued: 0 };
|
||||
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return { enqueued: 0 };
|
||||
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return { enqueued: 0 };
|
||||
if (offlineGeoReady()) return { enqueued: 0 };
|
||||
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* `AbortMultipartUpload` can only touch an upload this worker started, never
|
||||
* a finished object. See docs/ops/backup-restore.md.
|
||||
*/
|
||||
import { envOr } from "@/lib/env";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
@@ -102,7 +103,7 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
|
||||
bucket,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region: process.env.BACKUP_S3_REGION ?? "auto",
|
||||
region: envOr("BACKUP_S3_REGION", "auto"),
|
||||
encryptionKey: decodeBackupKey(encRaw),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { envOr } from "@/lib/env";
|
||||
import { hostname } from "node:os";
|
||||
import type { LoggingConfig, LogLevel, WideEvent } from "./types";
|
||||
import { LOG_LEVEL_PRIORITY } from "./types";
|
||||
@@ -8,7 +9,7 @@ let cachedConfig: LoggingConfig | null = null;
|
||||
export function getLoggingConfig(): LoggingConfig {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
const level = (process.env.LOG_LEVEL ?? "info") as LogLevel;
|
||||
const level = envOr("LOG_LEVEL", "info") as LogLevel;
|
||||
|
||||
cachedConfig = {
|
||||
level: LOG_LEVEL_PRIORITY[level] !== undefined ? level : "info",
|
||||
|
||||
Reference in new issue
Block a user