فهرست منبع

RHL-005-feat(env): add environment variable validation and normalization functions with tests

Code_Uwe 2 روز پیش
والد
کامیت
501dede489
2فایلهای تغییر یافته به همراه318 افزوده شده و 0 حذف شده
  1. 190 0
      lib/config/validateEnv.js
  2. 128 0
      lib/config/validateEnv.test.js

+ 190 - 0
lib/config/validateEnv.js

@@ -0,0 +1,190 @@
+export const REQUIRED_ENV_VARS = [
+	"MONGODB_URI",
+	"SESSION_SECRET",
+	"NAS_ROOT_PATH",
+];
+
+export const ALLOWED_NODE_ENVS = new Set(["development", "test", "production"]);
+export const MIN_SESSION_SECRET_LENGTH = 32;
+
+function isBlank(value) {
+	return value === undefined || value === null || String(value).trim() === "";
+}
+
+function normalizeString(value) {
+	return String(value).trim();
+}
+
+function normalizeUnixPath(value) {
+	let p = normalizeString(value);
+	// Remove trailing slashes (but keep "/" as-is)
+	if (p.length > 1) p = p.replace(/\/+$/, "");
+	return p;
+}
+
+function containsDotDotSegment(p) {
+	// Reject "/../", "/..", "../", etc.
+	return /(^|\/)\.\.(\/|$)/.test(p);
+}
+
+function looksLikePlaceholderSecret(value) {
+	const s = normalizeString(value).toLowerCase();
+	return (
+		s.includes("change-me") ||
+		s.includes("changeme") ||
+		s.includes("replace-me") ||
+		s.includes("replace_this") ||
+		s === "secret" ||
+		s === "password"
+	);
+}
+
+function validateMongoUri(uri) {
+	return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
+}
+
+function parsePort(value) {
+	const raw = normalizeString(value);
+	if (!/^\d+$/.test(raw)) return { ok: false, value: null };
+	const n = Number(raw);
+	if (!Number.isInteger(n) || n < 1 || n > 65535)
+		return { ok: false, value: null };
+	return { ok: true, value: n };
+}
+
+function buildEnvError(missing, invalid) {
+	const lines = [];
+	lines.push("Invalid environment configuration.");
+
+	if (missing.length > 0) {
+		lines.push("");
+		lines.push("Missing required environment variables:");
+		for (const key of missing) lines.push(`- ${key}`);
+	}
+
+	if (invalid.length > 0) {
+		lines.push("");
+		lines.push("Invalid environment variables:");
+		for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`);
+	}
+
+	lines.push("");
+	lines.push(
+		"Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)."
+	);
+
+	const err = new Error(lines.join("\n"));
+	err.code = "ENV_INVALID";
+	err.missing = missing;
+	err.invalid = invalid;
+	return err;
+}
+
+/**
+ * Validates and normalizes environment variables.
+ * This function is pure: pass in an env object (e.g. process.env) and it returns config or throws.
+ *
+ * @param {Record<string, any>} env
+ * @returns {{
+ *  mongodbUri: string,
+ *  sessionSecret: string,
+ *  nasRootPath: string,
+ *  nodeEnv: "development" | "test" | "production",
+ *  port?: number
+ * }}
+ */
+export function validateEnv(env) {
+	const e = env ?? {};
+	const missing = [];
+	const invalid = [];
+
+	for (const key of REQUIRED_ENV_VARS) {
+		if (isBlank(e[key])) missing.push(key);
+	}
+
+	const mongodbUri = !isBlank(e.MONGODB_URI)
+		? normalizeString(e.MONGODB_URI)
+		: "";
+	if (mongodbUri && !validateMongoUri(mongodbUri)) {
+		invalid.push({
+			key: "MONGODB_URI",
+			message: 'must start with "mongodb://" or "mongodb+srv://"',
+		});
+	}
+
+	const sessionSecret = !isBlank(e.SESSION_SECRET)
+		? normalizeString(e.SESSION_SECRET)
+		: "";
+	if (sessionSecret) {
+		if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
+			invalid.push({
+				key: "SESSION_SECRET",
+				message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`,
+			});
+		}
+		if (looksLikePlaceholderSecret(sessionSecret)) {
+			invalid.push({
+				key: "SESSION_SECRET",
+				message:
+					"looks like a placeholder (replace it with a strong random secret)",
+			});
+		}
+	}
+
+	const nasRootPath = !isBlank(e.NAS_ROOT_PATH)
+		? normalizeUnixPath(e.NAS_ROOT_PATH)
+		: "";
+	if (nasRootPath) {
+		if (!nasRootPath.startsWith("/")) {
+			invalid.push({
+				key: "NAS_ROOT_PATH",
+				message: 'must be an absolute Unix path (starts with "/")',
+			});
+		}
+		if (containsDotDotSegment(nasRootPath)) {
+			invalid.push({
+				key: "NAS_ROOT_PATH",
+				message: 'must not contain ".." path segments',
+			});
+		}
+	}
+
+	const nodeEnvRaw = !isBlank(e.NODE_ENV)
+		? normalizeString(e.NODE_ENV)
+		: "development";
+	if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) {
+		invalid.push({
+			key: "NODE_ENV",
+			message: 'must be one of "development", "test", "production"',
+		});
+	}
+
+	let port;
+	if (!isBlank(e.PORT)) {
+		const parsed = parsePort(e.PORT);
+		if (!parsed.ok) {
+			invalid.push({
+				key: "PORT",
+				message: "must be an integer between 1 and 65535",
+			});
+		} else {
+			port = parsed.value;
+		}
+	}
+
+	if (missing.length > 0 || invalid.length > 0) {
+		throw buildEnvError(missing, invalid);
+	}
+
+	/** @type {any} */
+	const cfg = {
+		mongodbUri,
+		sessionSecret,
+		nasRootPath,
+		nodeEnv: nodeEnvRaw,
+	};
+
+	if (port !== undefined) cfg.port = port;
+
+	return cfg;
+}

+ 128 - 0
lib/config/validateEnv.test.js

@@ -0,0 +1,128 @@
+import { describe, it, expect } from "vitest";
+import { validateEnv, MIN_SESSION_SECRET_LENGTH } from "./validateEnv.js";
+
+function validSecret() {
+	return "x".repeat(MIN_SESSION_SECRET_LENGTH);
+}
+
+describe("validateEnv", () => {
+	it("returns normalized config for a valid env", () => {
+		const cfg = validateEnv({
+			MONGODB_URI: "mongodb://localhost:27017/rhl",
+			SESSION_SECRET: validSecret(),
+			NAS_ROOT_PATH: "/mnt/niederlassungen/",
+			// NODE_ENV intentionally omitted -> defaults to "development"
+			PORT: "3000",
+		});
+
+		expect(cfg.mongodbUri).toBe("mongodb://localhost:27017/rhl");
+		expect(cfg.sessionSecret).toBe(validSecret());
+		expect(cfg.nasRootPath).toBe("/mnt/niederlassungen"); // trailing slash removed
+		expect(cfg.nodeEnv).toBe("development");
+		expect(cfg.port).toBe(3000);
+	});
+
+	it("throws with a clear error if required vars are missing", () => {
+		try {
+			validateEnv({});
+			throw new Error("Expected validateEnv to throw");
+		} catch (err) {
+			expect(err.code).toBe("ENV_INVALID");
+			expect(err.missing).toEqual([
+				"MONGODB_URI",
+				"SESSION_SECRET",
+				"NAS_ROOT_PATH",
+			]);
+			expect(String(err.message)).toContain(
+				"Missing required environment variables:"
+			);
+			expect(String(err.message)).toContain("- MONGODB_URI");
+			expect(String(err.message)).toContain("- SESSION_SECRET");
+			expect(String(err.message)).toContain("- NAS_ROOT_PATH");
+		}
+	});
+
+	it("rejects invalid MONGODB_URI schemes", () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "http://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+				NODE_ENV: "production",
+			})
+		).toThrow(/MONGODB_URI/i);
+	});
+
+	it("rejects too-short SESSION_SECRET", () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: "short-secret",
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+			})
+		).toThrow(/SESSION_SECRET/i);
+	});
+
+	it("rejects placeholder-like SESSION_SECRET even if long enough", () => {
+		const secret = `change-me-${"x".repeat(64)}`;
+
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: secret,
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+			})
+		).toThrow(/placeholder/i);
+	});
+
+	it("rejects NAS_ROOT_PATH that is not absolute", () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "mnt/niederlassungen",
+			})
+		).toThrow(/NAS_ROOT_PATH/i);
+	});
+
+	it('rejects NAS_ROOT_PATH containing ".." segments', () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "/mnt/../etc",
+			})
+		).toThrow(/NAS_ROOT_PATH/i);
+	});
+
+	it("rejects invalid NODE_ENV values", () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+				NODE_ENV: "staging",
+			})
+		).toThrow(/NODE_ENV/i);
+	});
+
+	it("rejects invalid PORT values", () => {
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+				PORT: "70000",
+			})
+		).toThrow(/PORT/i);
+
+		expect(() =>
+			validateEnv({
+				MONGODB_URI: "mongodb://localhost:27017/rhl",
+				SESSION_SECRET: validSecret(),
+				NAS_ROOT_PATH: "/mnt/niederlassungen",
+				PORT: "abc",
+			})
+		).toThrow(/PORT/i);
+	});
+});