diff --git a/package.json b/package.json index 5cf455b990..cb1b109362 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "@sinclair/typebox": "^0.34.15", "ajv-formats": "^3.0.1", "close-with-grace": "^2.2.0", + "dotenv": "^16.4.7", "drizzle-orm": "^0.39.1", "drizzle-zod": "0.6.1", - "dotenv": "^16.0.3", "env-schema": "^6.0.1", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", @@ -96,6 +96,7 @@ "start_development_server_with_debugger": "tsx watch --inspect=${API_DEBUGGER_HOST:-127.0.0.1}:${API_DEBUGGER_PORT:-9229} ./src/index.ts", "start_production_server": "pnpm build_production && node ./dist/index.js", "start_production_server_with_debugger": "pnpm build_production && node --inspect=${API_DEBUGGER_HOST:-127.0.0.1}:${API_DEBUGGER_PORT:-9229} ./dist/index.js", + "setup": "tsx setup.ts", "upgrade_drizzle_metadata": "drizzle-kit up" }, "type": "module", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 808f8b1819..6abb60ab7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,7 +42,7 @@ importers: specifier: ^2.2.0 version: 2.2.0 dotenv: - specifier: ^16.0.3 + specifier: ^16.4.7 version: 16.4.7 drizzle-orm: specifier: ^0.39.1 diff --git a/setup.ts b/setup.ts new file mode 100644 index 0000000000..3d9d85985b --- /dev/null +++ b/setup.ts @@ -0,0 +1,11 @@ +import { setup } from "~/src/setup/setup"; + +setup().catch((err) => { + console.error(`Setup failed: ${err.message}`); + console.error("Error details:", { + type: err.name, + code: err.code, + stack: err.stack, + }); + process.exit(1); +}); diff --git a/src/setup/setup.ts b/src/setup/setup.ts new file mode 100644 index 0000000000..91ad348130 --- /dev/null +++ b/src/setup/setup.ts @@ -0,0 +1,591 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import process from "node:process"; +import dotenv from "dotenv"; +import inquirer from "inquirer"; +import { updateEnvVariable } from "./updateEnvVariable"; + +interface SetupAnswers { + [key: string]: string; +} + +async function promptInput( + name: string, + message: string, + defaultValue?: string, + validate?: (input: string) => true | string, +): Promise { + const { [name]: result } = await inquirer.prompt([ + { type: "input", name, message, default: defaultValue, validate }, + ]); + return result; +} + +async function promptList( + name: string, + message: string, + choices: string[], + defaultValue?: string, +): Promise { + const { [name]: result } = await inquirer.prompt([ + { type: "list", name, message, choices, default: defaultValue }, + ]); + return result; +} + +async function promptConfirm( + name: string, + message: string, + defaultValue?: boolean, +): Promise { + const { [name]: result } = await inquirer.prompt([ + { type: "confirm", name, message, default: defaultValue }, + ]); + return result; +} + +const envFileName = ".env"; + +export function generateJwtSecret(): string { + try { + return crypto.randomBytes(64).toString("hex"); + } catch (err) { + console.error( + "⚠️ Warning: Permission denied while generating JWT secret. Ensure the process has sufficient filesystem access.", + err, + ); + throw new Error("Failed to generate JWT secret"); + } +} + +export function validateURL(input: string): true | string { + if (!input?.trim()) { + return "Please enter a valid URL."; + } + + try { + const trimmedInput = input.trim(); + const url = new URL(trimmedInput); + + if (!["http:", "https:"].includes(url.protocol)) { + return "Please enter a valid URL with http:// or https:// protocol."; + } + + if (!url.hostname) { + return "Please enter a valid URL."; + } + + return true; + } catch { + return "Please enter a valid URL."; + } +} + +export function validatePort(input: string): true | string { + const portNumber = Number(input); + if (Number.isNaN(portNumber) || portNumber <= 0 || portNumber > 65535) { + return "Please enter a valid port number (1-65535)."; + } + return true; +} + +export function validateEmail(input: string): true | string { + if (!input.trim()) { + return "Email cannot be empty."; + } + if (input.length > 254) { + return "Email is too long."; + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(input)) { + return "Invalid email format. Please enter a valid email address."; + } + return true; +} + +export function validateCloudBeaverAdmin(input: string): true | string { + if (!input) return "Admin name is required"; + if (input.length < 3) return "Admin name must be at least 3 characters long"; + if (!/^[a-zA-Z0-9_]+$/.test(input)) + return "Admin name can only contain letters, numbers, and underscores"; + return true; +} + +export function validateCloudBeaverPassword(input: string): true | string { + if (!input) return "Password is required"; + if (input.length < 8) return "Password must be at least 8 characters long"; + if (!/[A-Za-z]/.test(input) || !/[0-9]/.test(input)) { + return "Password must contain both letters and numbers"; + } + return true; +} + +export function validateCloudBeaverURL(input: string): true | string { + if (!input) return "Server URL is required"; + try { + const url = new URL(input); + if (!["http:", "https:"].includes(url.protocol)) { + return "URL must use HTTP or HTTPS protocol"; + } + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + if (!/^\d+$/.test(port) || Number.parseInt(port) > 65535) { + return "Invalid port in URL"; + } + return true; + } catch { + return "Invalid URL format"; + } +} + +function handlePromptError(err: unknown): never { + console.error(err); + if (fs.existsSync(".env.backup")) { + fs.copyFileSync(".env.backup", ".env"); + } + process.exit(1); +} + +export function checkEnvFile(): boolean { + return fs.existsSync(envFileName); +} + +export function initializeEnvFile(answers: SetupAnswers): void { + if (fs.existsSync(envFileName)) { + fs.copyFileSync(envFileName, `${envFileName}.backup`); + console.log(`✅ Backup created at ${envFileName}.backup`); + } + + const envFileToUse = + answers.CI === "true" ? "envFiles/.env.ci" : "envFiles/.env.devcontainer"; + + if (!fs.existsSync(envFileToUse)) { + console.warn(`⚠️ Warning: Configuration file '${envFileToUse}' is missing.`); + throw new Error( + `Configuration file '${envFileToUse}' is missing. Please create the file or use a different environment configuration.`, + ); + } + + try { + const parsedEnv = dotenv.parse(fs.readFileSync(envFileToUse)); + dotenv.config({ path: envFileName }); + + const safeContent = Object.entries(parsedEnv) + .map(([key, value]) => { + const escaped = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n"); + return `${key}="${escaped}"`; + }) + .join("\n"); + + fs.writeFileSync(envFileName, safeContent, { encoding: "utf-8" }); + console.log( + `✅ Environment variables loaded successfully from ${envFileToUse}`, + ); + } catch (error) { + console.error( + `❌ Error: Failed to load environment file '${envFileToUse}'.`, + ); + console.error(error instanceof Error ? error.message : error); + throw new Error( + "Failed to load environment file. Please check file permissions and ensure it contains valid environment variables.", + ); + } +} + +export async function setCI(answers: SetupAnswers): Promise { + try { + answers.CI = await promptList("CI", "Set CI:", ["true", "false"], "false"); + } catch (err) { + console.error(err); + handlePromptError(err); + } + return answers; +} + +export async function administratorEmail( + answers: SetupAnswers, +): Promise { + try { + answers.API_ADMINISTRATOR_USER_EMAIL_ADDRESS = await promptInput( + "API_ADMINISTRATOR_USER_EMAIL_ADDRESS", + "Enter email:", + "administrator@email.com", + validateEmail, + ); + } catch (err) { + console.log(err); + handlePromptError(err); + } + return answers; +} + +export async function apiSetup(answers: SetupAnswers): Promise { + try { + answers.API_BASE_URL = await promptInput( + "API_BASE_URL", + "API base URL:", + "http://127.0.0.1:4000", + validateURL, + ); + answers.API_HOST = await promptInput("API_HOST", "API host:", "0.0.0.0"); + + answers.API_PORT = await promptInput( + "API_PORT", + "API port:", + "4000", + validatePort, + ); + + answers.API_IS_APPLY_DRIZZLE_MIGRATIONS = await promptList( + "API_IS_APPLY_DRIZZLE_MIGRATIONS", + "Apply Drizzle migrations?", + ["true", "false"], + "true", + ); + + answers.API_IS_GRAPHIQL = await promptList( + "API_IS_GRAPHIQL", + "Enable GraphQL?", + ["true", "false"], + answers.CI === "false" ? "true" : "false", + ); + + answers.API_IS_PINO_PRETTY = await promptList( + "API_IS_PINO_PRETTY", + "Enable Pino Pretty logs?", + ["true", "false"], + answers.CI === "false" ? "true" : "false", + ); + + answers.API_JWT_EXPIRES_IN = await promptInput( + "API_JWT_EXPIRES_IN", + "JWT expiration (ms):", + "2592000000", + ); + + const jwtSecret = generateJwtSecret(); + answers.API_JWT_SECRET = await promptInput( + "API_JWT_SECRET", + "JWT secret:", + jwtSecret, + (input: string) => { + const trimmed = input.trim(); + if (trimmed.length < 128) { + return "JWT secret must be at least 128 characters long."; + } + return true; + }, + ); + + answers.API_LOG_LEVEL = await promptList( + "API_LOG_LEVEL", + "Log level:", + ["info", "debug"], + answers.CI === "true" ? "info" : "debug", + ); + + answers.API_MINIO_ACCESS_KEY = await promptInput( + "API_MINIO_ACCESS_KEY", + "Minio access key:", + "talawa", + ); + + answers.API_MINIO_END_POINT = await promptInput( + "API_MINIO_END_POINT", + "Minio endpoint:", + "minio", + ); + + answers.API_MINIO_PORT = await promptInput( + "API_MINIO_PORT", + "Minio port:", + "9000", + ); + + answers.API_MINIO_SECRET_KEY = await promptInput( + "API_MINIO_SECRET_KEY", + "Minio secret key:", + "password", + ); + + answers.API_MINIO_TEST_END_POINT = await promptInput( + "API_MINIO_TEST_END_POINT", + "Minio test endpoint:", + "minio-test", + ); + + answers.API_MINIO_USE_SSL = await promptList( + "API_MINIO_USE_SSL", + "Use Minio SSL?", + ["true", "false"], + "false", + ); + + answers.API_POSTGRES_DATABASE = await promptInput( + "API_POSTGRES_DATABASE", + "Postgres database:", + "talawa", + ); + + answers.API_POSTGRES_HOST = await promptInput( + "API_POSTGRES_HOST", + "Postgres host:", + "postgres", + ); + + answers.API_POSTGRES_PASSWORD = await promptInput( + "API_POSTGRES_PASSWORD", + "Postgres password:", + "password", + ); + + answers.API_POSTGRES_PORT = await promptInput( + "API_POSTGRES_PORT", + "Postgres port:", + "5432", + ); + + answers.API_POSTGRES_SSL_MODE = await promptList( + "API_POSTGRES_SSL_MODE", + "Use Postgres SSL?", + ["true", "false"], + "false", + ); + + answers.API_POSTGRES_TEST_HOST = await promptInput( + "API_POSTGRES_TEST_HOST", + "Postgres test host:", + "postgres-test", + ); + + answers.API_POSTGRES_USER = await promptInput( + "API_POSTGRES_USER", + "Postgres user:", + "talawa", + ); + } catch (err) { + handlePromptError(err); + } + + return answers; +} + +export async function cloudbeaverSetup( + answers: SetupAnswers, +): Promise { + answers.CLOUDBEAVER_ADMIN_NAME = await promptInput( + "CLOUDBEAVER_ADMIN_NAME", + "CloudBeaver admin name:", + "talawa", + validateCloudBeaverAdmin, + ); + + answers.CLOUDBEAVER_ADMIN_PASSWORD = await promptInput( + "CLOUDBEAVER_ADMIN_PASSWORD", + "CloudBeaver admin password:", + "password", + validateCloudBeaverPassword, + ); + + answers.CLOUDBEAVER_MAPPED_HOST_IP = await promptInput( + "CLOUDBEAVER_MAPPED_HOST_IP", + "CloudBeaver mapped host IP:", + "127.0.0.1", + ); + + answers.CLOUDBEAVER_MAPPED_PORT = await promptInput( + "CLOUDBEAVER_MAPPED_PORT", + "CloudBeaver mapped port:", + "8978", + validatePort, + ); + + answers.CLOUDBEAVER_SERVER_NAME = await promptInput( + "CLOUDBEAVER_SERVER_NAME", + "CloudBeaver server name:", + "Talawa CloudBeaver Server", + ); + + answers.CLOUDBEAVER_SERVER_URL = await promptInput( + "CLOUDBEAVER_SERVER_URL", + "CloudBeaver server URL:", + "http://127.0.0.1:8978", + validateCloudBeaverURL, + ); + + return answers; +} + +export async function minioSetup(answers: SetupAnswers): Promise { + answers.MINIO_BROWSER = await promptInput( + "MINIO_BROWSER", + "Minio browser (on/off):", + answers.CI === "true" ? "off" : "on", + ); + + if (answers.CI === "false") { + answers.MINIO_API_MAPPED_HOST_IP = await promptInput( + "MINIO_API_MAPPED_HOST_IP", + "Minio API mapped host IP:", + "127.0.0.1", + ); + + answers.MINIO_API_MAPPED_PORT = await promptInput( + "MINIO_API_MAPPED_PORT", + "Minio API mapped port:", + "9000", + validatePort, + ); + + answers.MINIO_CONSOLE_MAPPED_HOST_IP = await promptInput( + "MINIO_CONSOLE_MAPPED_HOST_IP", + "Minio console mapped host IP:", + "127.0.0.1", + ); + + answers.MINIO_CONSOLE_MAPPED_PORT = await promptInput( + "MINIO_CONSOLE_MAPPED_PORT", + "Minio console mapped port:", + "9001", + validatePort, + ); + + if (answers.MINIO_API_MAPPED_PORT === answers.MINIO_CONSOLE_MAPPED_PORT) { + throw new Error( + "Port conflict detected: MinIO API and Console ports must be different", + ); + } + } + + answers.MINIO_ROOT_PASSWORD = await promptInput( + "MINIO_ROOT_PASSWORD", + "Minio root password:", + "password", + ); + + answers.MINIO_ROOT_USER = await promptInput( + "MINIO_ROOT_USER", + "Minio root user:", + "talawa", + ); + + return answers; +} + +export async function postgresSetup( + answers: SetupAnswers, +): Promise { + answers.POSTGRES_DB = await promptInput( + "POSTGRES_DB", + "Postgres database:", + "talawa", + ); + + if (answers.CI === "false") { + answers.POSTGRES_MAPPED_HOST_IP = await promptInput( + "POSTGRES_MAPPED_HOST_IP", + "Postgres mapped host IP:", + "127.0.0.1", + ); + + answers.POSTGRES_MAPPED_PORT = await promptInput( + "POSTGRES_MAPPED_PORT", + "Postgres mapped port:", + "5432", + validatePort, + ); + } + + answers.POSTGRES_PASSWORD = await promptInput( + "POSTGRES_PASSWORD", + "Postgres password:", + "password", + ); + + answers.POSTGRES_USER = await promptInput( + "POSTGRES_USER", + "Postgres user:", + "talawa", + ); + + return answers; +} + +export async function setup(): Promise { + let answers: SetupAnswers = {}; + if (checkEnvFile()) { + const envReconfigure = await promptConfirm( + "envReconfigure", + "Env file found. Re-configure? (Y)/N", + true, + ); + if (!envReconfigure) { + process.exit(0); + } + } + + dotenv.config({ path: envFileName }); + + process.on("SIGINT", () => { + console.log("\nProcess interrupted! Undoing changes..."); + answers = {}; + if (fs.existsSync(".env.backup")) { + fs.copyFileSync(".env.backup", ".env"); + } + process.exit(1); + }); + + answers = await setCI(answers); + initializeEnvFile(answers); + + const useDefaultApi = await promptConfirm( + "useDefaultApi", + "Use recommended default API settings? (Y)/N", + true, + ); + + if (!useDefaultApi) { + answers = await apiSetup(answers); + } + + const useDefaultMinio = await promptConfirm( + "useDefaultMinio", + "Use recommended default Minio settings? (Y)/N", + true, + ); + + if (!useDefaultMinio) { + answers = await minioSetup(answers); + } + + if (answers.CI === "false") { + const useDefaultCloudbeaver = await promptConfirm( + "useDefaultCloudbeaver", + "Use recommended default CloudBeaver settings? (Y)/N", + true, + ); + if (!useDefaultCloudbeaver) { + answers = await cloudbeaverSetup(answers); + } + } + + const useDefaultPostgres = await promptConfirm( + "useDefaultPostgres", + "Use recommended default Postgres settings? (Y)/N", + true, + ); + if (!useDefaultPostgres) { + answers = await postgresSetup(answers); + } + + answers = await administratorEmail(answers); + + updateEnvVariable(answers); + console.log("Configuration complete."); + if (fs.existsSync(".env.backup")) { + fs.unlinkSync(".env.backup"); + } + return answers; +} diff --git a/src/setup/updateEnvVariable.ts b/src/setup/updateEnvVariable.ts new file mode 100644 index 0000000000..25f227737a --- /dev/null +++ b/src/setup/updateEnvVariable.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; + +/** + * Updates environment variables in the .env or .env_test file and synchronizes them with `process.env`. + * @param config - An object containing key-value pairs where the keys are the environment variable names and + * the values are the new values for those variables. + */ +export function updateEnvVariable(config: { + [key: string]: string | number; +}): void { + const envFileName = process.env.NODE_ENV === "test" ? ".env_test" : ".env"; + + const backupFile = `${envFileName}.backup`; + if (fs.existsSync(envFileName)) { + fs.copyFileSync(envFileName, backupFile); + } + + try { + const existingContent: string = fs.existsSync(envFileName) + ? fs.readFileSync(envFileName, "utf8") + : ""; + + let updatedContent: string = existingContent; + + for (const key in config) { + const value = config[key]; + const regex = new RegExp( + `^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=.*`, + "gm", + ); + + if (regex.test(updatedContent)) { + updatedContent = updatedContent.replace(regex, `${key}=${value}`); + } else { + updatedContent += `\n${key}=${value}`; + } + + process.env[key] = String(value); + } + + fs.writeFileSync(envFileName, updatedContent, "utf8"); + } catch (error) { + if (fs.existsSync(backupFile)) { + fs.copyFileSync(backupFile, envFileName); + } + throw error; + } +} diff --git a/test/setup/administratorEmail.test.ts b/test/setup/administratorEmail.test.ts new file mode 100644 index 0000000000..eb56f65533 --- /dev/null +++ b/test/setup/administratorEmail.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { administratorEmail } from "~/src/setup/setup"; +import { validateEmail } from "~/src/setup/setup"; +import * as SetupModule from "~/src/setup/setup"; + +vi.mock("inquirer"); + +describe("Setup -> askForAdministratorEmail", () => { + const originalEmail = process.env.API_ADMINISTRATOR_USER_EMAIL_ADDRESS; + + afterEach(() => { + process.env.API_ADMINISTRATOR_USER_EMAIL_ADDRESS = originalEmail; + }); + + it("should prompt the user for an email and update the email env", async () => { + const mockedEmail = "testuser@email.com"; + + vi.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + API_ADMINISTRATOR_USER_EMAIL_ADDRESS: mockedEmail, + }); + + const answers = await administratorEmail({}); + + expect(answers.API_ADMINISTRATOR_USER_EMAIL_ADDRESS).toBe(mockedEmail); + }); + + it("should return true for valid email addresses", () => { + expect(validateEmail("user@example.com")).toBe(true); + expect(validateEmail("test.email@domain.io")).toBe(true); + expect(validateEmail("user+tag@example.co.uk")).toBe(true); + expect(validateEmail("user@xn--80ak6aa92e.com")).toBe(true); + }); + + it("should return an error message for invalid email addresses", () => { + expect(validateEmail("invalid-email")).toBe( + "Invalid email format. Please enter a valid email address.", + ); + expect(validateEmail(" ")).toBe("Email cannot be empty."); + expect(validateEmail(`${"a".repeat(255)}@example.com`)).toBe( + "Email is too long.", + ); + }); + + it("should log error, create a backup, and exit with code 1 if inquirer fails", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + vi.spyOn(fs, "existsSync").mockReturnValue(true); + vi.spyOn(fs, "copyFileSync").mockImplementation(() => {}); + + const promptError = new Error("inquirer failure"); + vi.spyOn(inquirer, "prompt").mockRejectedValueOnce(promptError); + + await expect(SetupModule.administratorEmail({})).rejects.toThrow( + "process.exit called", + ); + expect(consoleLogSpy).toHaveBeenCalledWith(promptError); + expect(fs.existsSync).toHaveBeenCalledWith(".env.backup"); + expect(fs.copyFileSync).toHaveBeenCalledWith(".env.backup", ".env"); + expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it("should log error but not create a backup if .env.backup is missing", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const promptError = new Error("inquirer failure"); + vi.spyOn(inquirer, "prompt").mockRejectedValueOnce(promptError); + + await expect(SetupModule.administratorEmail({})).rejects.toThrow( + "process.exit called", + ); + + expect(consoleLogSpy).toHaveBeenCalledWith(promptError); + expect(fs.existsSync).toHaveBeenCalledWith(".env.backup"); + expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); +}); diff --git a/test/setup/apiSetup.test.ts b/test/setup/apiSetup.test.ts new file mode 100644 index 0000000000..3089bf086b --- /dev/null +++ b/test/setup/apiSetup.test.ts @@ -0,0 +1,215 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import dotenv from "dotenv"; +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + apiSetup, + generateJwtSecret, + setup, + validatePort, + validateURL, +} from "~/src/setup/setup"; + +vi.mock("inquirer"); + +describe("Setup -> apiSetup", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetAllMocks(); + }); + + it("should prompt the user for API configuration and update environment variables", async () => { + const mockResponses = [ + { CI: "true" }, + { useDefaultApi: false }, + { API_BASE_URL: "http://localhost:5000" }, + { API_HOST: "127.0.0.1" }, + { API_PORT: "5000" }, + { API_IS_APPLY_DRIZZLE_MIGRATIONS: "true" }, + { API_IS_GRAPHIQL: "false" }, + { API_IS_PINO_PRETTY: "false" }, + { API_JWT_EXPIRES_IN: "3600000" }, + { API_JWT_SECRET: "mocked-secret" }, + { API_LOG_LEVEL: "info" }, + { API_MINIO_ACCESS_KEY: "mocked-access-key" }, + { API_MINIO_END_POINT: "mocked-endpoint" }, + { API_MINIO_PORT: "9001" }, + { API_MINIO_SECRET_KEY: "mocked-secret-key" }, + { API_MINIO_TEST_END_POINT: "mocked-test-endpoint" }, + { API_MINIO_USE_SSL: "true" }, + { API_POSTGRES_DATABASE: "mocked-database" }, + { API_POSTGRES_HOST: "mocked-host" }, + { API_POSTGRES_PASSWORD: "mocked-password" }, + { API_POSTGRES_PORT: "5433" }, + { API_POSTGRES_SSL_MODE: "true" }, + { API_POSTGRES_TEST_HOST: "mocked-test-host" }, + { API_POSTGRES_USER: "mocked-user" }, + { useDefaultMinio: true }, + { useDefaultPostgres: true }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@email.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + const answers = await setup(); + dotenv.config({ path: ".env" }); + + const expectedEnv = { + API_BASE_URL: "http://localhost:5000", + API_HOST: "127.0.0.1", + API_PORT: "5000", + API_IS_APPLY_DRIZZLE_MIGRATIONS: "true", + API_IS_GRAPHIQL: "false", + API_IS_PINO_PRETTY: "false", + API_JWT_EXPIRES_IN: "3600000", + API_JWT_SECRET: "mocked-secret", + API_LOG_LEVEL: "info", + API_MINIO_ACCESS_KEY: "mocked-access-key", + API_MINIO_END_POINT: "mocked-endpoint", + API_MINIO_PORT: "9001", + API_MINIO_SECRET_KEY: "mocked-secret-key", + API_MINIO_TEST_END_POINT: "mocked-test-endpoint", + API_MINIO_USE_SSL: "true", + API_POSTGRES_DATABASE: "mocked-database", + API_POSTGRES_HOST: "mocked-host", + API_POSTGRES_PASSWORD: "mocked-password", + API_POSTGRES_PORT: "5433", + API_POSTGRES_SSL_MODE: "true", + API_POSTGRES_TEST_HOST: "mocked-test-host", + API_POSTGRES_USER: "mocked-user", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(answers[key]).toBe(value); + } + }); +}); +describe("validateURL", () => { + it("should validate standard URLs", () => { + expect(validateURL("https://example.com")).toBe(true); + expect(validateURL("http://localhost:3000")).toBe(true); + }); + + it("should validate URLs with complex components", () => { + expect(validateURL("https://sub.example.com:8080/path")).toBe(true); + expect(validateURL("http://sub.domain.example.com/path?query=1")).toBe( + true, + ); + expect(validateURL("https://example.com/path#fragment")).toBe(true); + }); + + it("should validate IP addresses", () => { + expect(validateURL("http://127.0.0.1:4000")).toBe(true); + expect(validateURL("https://[::1]:8080")).toBe(true); + expect(validateURL("http://192.168.1.1")).toBe(true); + }); + + it("should reject invalid protocols", () => { + expect(validateURL("ftp://example.com")).toBe( + "Please enter a valid URL with http:// or https:// protocol.", + ); + expect(validateURL("ws://example.com")).toBe( + "Please enter a valid URL with http:// or https:// protocol.", + ); + }); + + it("should reject malformed URLs", () => { + expect(validateURL("http://")).toBe("Please enter a valid URL."); + expect(validateURL("http://example.com:abc")).toBe( + "Please enter a valid URL.", + ); + expect(validateURL(" ")).toBe("Please enter a valid URL."); + expect(validateURL("")).toBe("Please enter a valid URL."); + }); +}); + +describe("validatePort", () => { + it("should return true for valid port numbers", () => { + expect(validatePort("80")).toBe(true); + expect(validatePort("443")).toBe(true); + expect(validatePort("65535")).toBe(true); + expect(validatePort("1")).toBe(true); + }); + + it("should return an error message for invalid port numbers", () => { + expect(validatePort("0")).toBe( + "Please enter a valid port number (1-65535).", + ); + expect(validatePort("65536")).toBe( + "Please enter a valid port number (1-65535).", + ); + expect(validatePort("-1")).toBe( + "Please enter a valid port number (1-65535).", + ); + expect(validatePort("not-a-number")).toBe( + "Please enter a valid port number (1-65535).", + ); + expect(validatePort(" ")).toBe( + "Please enter a valid port number (1-65535).", + ); + }); +}); + +describe("generateJwtSecret", () => { + it("should generate a 64-byte hex string", () => { + const secret = generateJwtSecret(); + expect(secret).toMatch(/^[a-f0-9]{128}$/); + }); + + it("should generate unique secrets", () => { + const secret1 = generateJwtSecret(); + const secret2 = generateJwtSecret(); + expect(secret1).not.toBe(secret2); + }); + + it("should log a warning and throw an error if randomBytes fails", () => { + const randomBytesSpy = vi + .spyOn(crypto, "randomBytes") + .mockImplementation(() => { + throw new Error("Permission denied"); + }); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + expect(() => generateJwtSecret()).toThrow("Failed to generate JWT secret"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "⚠️ Warning: Permission denied while generating JWT secret. Ensure the process has sufficient filesystem access.", + expect.any(Error), + ); + + randomBytesSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it("should handle prompt errors correctly", async () => { + const processExitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + const fsExistsSyncSpy = vi.spyOn(fs, "existsSync").mockReturnValue(true); + const fsCopyFileSyncSpy = vi + .spyOn(fs, "copyFileSync") + .mockImplementation(() => undefined); + + const mockError = new Error("Prompt failed"); + vi.spyOn(inquirer, "prompt").mockRejectedValueOnce(mockError); + + const consoleErrorSpy = vi.spyOn(console, "error"); + + await apiSetup({}); + + expect(consoleErrorSpy).toHaveBeenCalledWith(mockError); + expect(fsExistsSyncSpy).toHaveBeenCalledWith(".env.backup"); + expect(fsCopyFileSyncSpy).toHaveBeenCalledWith(".env.backup", ".env"); + expect(processExitSpy).toHaveBeenCalledWith(1); + + vi.clearAllMocks(); + }); +}); diff --git a/test/setup/cloudbeaverSetup.test.ts b/test/setup/cloudbeaverSetup.test.ts new file mode 100644 index 0000000000..7bbe513115 --- /dev/null +++ b/test/setup/cloudbeaverSetup.test.ts @@ -0,0 +1,101 @@ +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + setup, + validateCloudBeaverAdmin, + validateCloudBeaverPassword, + validateCloudBeaverURL, +} from "~/src/setup/setup"; + +vi.mock("inquirer"); + +describe("Setup -> cloudbeaverSetup", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetAllMocks(); + }); + + it("should prompt the user for CloudBeaver configuration and update process.env", async () => { + const mockResponses = [ + { envReconfigure: true }, + { CI: "false" }, + { useDefaultApi: true }, + { useDefaultMinio: true }, + { useDefaultCloudbeaver: false }, + { CLOUDBEAVER_ADMIN_NAME: "mocked-admin" }, + { CLOUDBEAVER_ADMIN_PASSWORD: "mocked-password" }, + { CLOUDBEAVER_MAPPED_HOST_IP: "127.0.0.1" }, + { CLOUDBEAVER_MAPPED_PORT: "8080" }, + { CLOUDBEAVER_SERVER_NAME: "Mocked Server" }, + { CLOUDBEAVER_SERVER_URL: "https://127.0.0.1:8080" }, + { useDefaultPostgres: true }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@email.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + const answers = await setup(); + + const expectedEnv = { + CLOUDBEAVER_ADMIN_NAME: "mocked-admin", + CLOUDBEAVER_ADMIN_PASSWORD: "mocked-password", + CLOUDBEAVER_MAPPED_HOST_IP: "127.0.0.1", + CLOUDBEAVER_MAPPED_PORT: "8080", + CLOUDBEAVER_SERVER_NAME: "Mocked Server", + CLOUDBEAVER_SERVER_URL: "https://127.0.0.1:8080", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(answers[key]).toBe(value); + } + }); +}); + +describe("CloudBeaver Validation", () => { + describe("validateCloudBeaverAdmin", () => { + it("should validate admin name format", () => { + expect(validateCloudBeaverAdmin("")).toBe("Admin name is required"); + expect(validateCloudBeaverAdmin("ab")).toBe( + "Admin name must be at least 3 characters long", + ); + expect(validateCloudBeaverAdmin("admin@123")).toBe( + "Admin name can only contain letters, numbers, and underscores", + ); + expect(validateCloudBeaverAdmin("admin_123")).toBe(true); + }); + }); + + describe("validateCloudBeaverPassword", () => { + it("should validate password strength", () => { + expect(validateCloudBeaverPassword("")).toBe("Password is required"); + expect(validateCloudBeaverPassword("weak")).toBe( + "Password must be at least 8 characters long", + ); + expect(validateCloudBeaverPassword("onlyletters")).toBe( + "Password must contain both letters and numbers", + ); + expect(validateCloudBeaverPassword("12345678")).toBe( + "Password must contain both letters and numbers", + ); + expect(validateCloudBeaverPassword("Strong2024")).toBe(true); + }); + }); + + describe("validateCloudBeaverURL", () => { + it("should validate server URL format", () => { + expect(validateCloudBeaverURL("")).toBe("Server URL is required"); + expect(validateCloudBeaverURL("invalid")).toBe("Invalid URL format"); + expect(validateCloudBeaverURL("ftp://127.0.0.1")).toBe( + "URL must use HTTP or HTTPS protocol", + ); + expect(validateCloudBeaverURL("http://127.0.0.1:8978")).toBe(true); + expect(validateCloudBeaverURL("https://localhost:8978")).toBe(true); + }); + }); +}); diff --git a/test/setup/envSetup.spec.ts b/test/setup/envSetup.spec.ts new file mode 100644 index 0000000000..d338bc9bac --- /dev/null +++ b/test/setup/envSetup.spec.ts @@ -0,0 +1,141 @@ +import fs from "node:fs"; +import inquirer from "inquirer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { checkEnvFile, initializeEnvFile, setCI } from "~/src/setup/setup"; +import * as SetupModule from "~/src/setup/setup"; + +vi.mock("dotenv", async (importOriginal) => { + const actual = await importOriginal(); + return { + default: actual, + config: vi.fn(), + parse: vi.fn((content) => { + if (content.includes("KEY1")) return { KEY1: "VAL1", KEY2: "VAL2" }; + if (content.includes("FOO")) return { FOO: "bar" }; + return {}; + }), + }; +}); + +const envFileName = ".env"; + +describe("checkEnvFile", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should return true if .env file exists", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + + const result = checkEnvFile(); + expect(fs.existsSync).toHaveBeenCalledWith(envFileName); + expect(result).toBe(true); + }); + + it("should return false if .env file does not exist", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + + const result = checkEnvFile(); + expect(fs.existsSync).toHaveBeenCalledWith(envFileName); + expect(result).toBe(false); + }); +}); + +describe("initializeEnvFile", () => { + const mockEnvContent = "KEY1=VAL1\nKEY2=VAL2"; + const envFileName = ".env"; + const backupEnvFile = ".env.backup"; + const devEnvFile = "envFiles/.env.devcontainer"; + + beforeEach(() => { + vi.resetAllMocks(); + + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("should read from .env.devcontainer when answers.CI is 'false'", async () => { + vi.spyOn(inquirer, "prompt").mockResolvedValue({ CI: "false" }); + const answers = await setCI({}); + vi.spyOn(fs, "readFileSync").mockReturnValue("KEY1=VAL1\nKEY2=VAL2"); + + initializeEnvFile(answers); + + expect(fs.readFileSync).toHaveBeenCalledWith("envFiles/.env.devcontainer"); + }); + + it("should create a backup of .env if it exists", async () => { + vi.spyOn(fs, "existsSync").mockImplementation( + (path) => path === envFileName || path === devEnvFile, + ); + vi.spyOn(fs, "copyFileSync").mockImplementation(() => {}); + vi.spyOn(fs, "readFileSync").mockReturnValue(mockEnvContent); + + initializeEnvFile({}); + + expect(fs.copyFileSync).toHaveBeenCalledWith(envFileName, backupEnvFile); + expect(console.log).toHaveBeenCalledWith( + `✅ Backup created at ${backupEnvFile}`, + ); + }); + + it("should throw an error if the environment file is missing", async () => { + vi.spyOn(fs, "existsSync").mockImplementation(() => false); + + expect(() => initializeEnvFile({})).toThrow( + "Configuration file 'envFiles/.env.devcontainer' is missing. Please create the file or use a different environment configuration.", + ); + }); + + it("should catch errors if reading the env file fails", async () => { + vi.spyOn(fs, "existsSync").mockImplementation( + (path) => path === devEnvFile, + ); + vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw new Error("File read error"); + }); + + expect(() => initializeEnvFile({})).toThrow( + "Failed to load environment file. Please check file permissions and ensure it contains valid environment variables.", + ); + + expect(console.error).toHaveBeenCalledWith( + `❌ Error: Failed to load environment file '${devEnvFile}'.`, + ); + expect(console.error).toHaveBeenCalledWith("File read error"); + }); + + it("should read from .env.ci when answers.CI is 'true'", async () => { + vi.spyOn(inquirer, "prompt").mockResolvedValue({ CI: "true" }); + const answers = await setCI({}); + vi.spyOn(fs, "readFileSync").mockReturnValue("FOO=bar"); + + initializeEnvFile(answers); + + expect(fs.readFileSync).toHaveBeenCalledWith("envFiles/.env.ci"); + }); + + it("should log error and exit with code 1 if inquirer fails", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const promptError = new Error("inquirer failure"); + vi.spyOn(inquirer, "prompt").mockRejectedValueOnce(promptError); + + await expect(SetupModule.setCI({})).rejects.toThrow("process.exit called"); + + expect(consoleErrorSpy).toHaveBeenCalledWith(promptError); + expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/test/setup/minioSetup.test.ts b/test/setup/minioSetup.test.ts new file mode 100644 index 0000000000..6425ff9830 --- /dev/null +++ b/test/setup/minioSetup.test.ts @@ -0,0 +1,101 @@ +import dotenv from "dotenv"; +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { minioSetup, setup } from "~/src/setup/setup"; + +vi.mock("inquirer"); + +describe("Setup -> minioSetup", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetAllMocks(); + }); + + it("should prompt the user for Minio configuration and update process.env", async () => { + const mockResponses = [ + { MINIO_BROWSER: "off" }, + { MINIO_ROOT_PASSWORD: "mocked-password" }, + { MINIO_ROOT_USER: "mocked-user" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + const answers = await minioSetup({}); + + const expectedEnv = { + MINIO_BROWSER: "off", + MINIO_ROOT_PASSWORD: "mocked-password", + MINIO_ROOT_USER: "mocked-user", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(answers[key]).toBe(value); + } + }); + + it("should prompt extended Minio config fields when CI=false", async () => { + const mockResponses = [ + { envReconfigure: true }, + { CI: "false" }, + { useDefaultApi: true }, + { useDefaultMinio: false }, + { MINIO_BROWSER: "on" }, + { MINIO_API_MAPPED_HOST_IP: "1.2.3.4" }, + { MINIO_API_MAPPED_PORT: "9000" }, + { MINIO_CONSOLE_MAPPED_HOST_IP: "1.2.3.5" }, + { MINIO_CONSOLE_MAPPED_PORT: "9001" }, + { MINIO_ROOT_PASSWORD: "mocked-password" }, + { MINIO_ROOT_USER: "mocked-user" }, + { useDefaultCloudbeaver: true }, + { useDefaultPostgres: true }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@email.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + await setup(); + dotenv.config({ path: ".env" }); + + const expectedEnv = { + MINIO_BROWSER: "on", + MINIO_API_MAPPED_HOST_IP: "1.2.3.4", + MINIO_CONSOLE_MAPPED_HOST_IP: "1.2.3.5", + MINIO_CONSOLE_MAPPED_PORT: "9001", + MINIO_ROOT_PASSWORD: "mocked-password", + MINIO_ROOT_USER: "mocked-user", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(process.env[key]).toBe(value); + } + }); + it("should detect port conflicts between API and Console ports", async () => { + const inputAnswers = { CI: "false" }; + + const mockResponses = [ + { MINIO_BROWSER: "on" }, + { MINIO_API_MAPPED_HOST_IP: "127.0.0.1" }, + { MINIO_API_MAPPED_PORT: "9000" }, + { MINIO_CONSOLE_MAPPED_HOST_IP: "127.0.0.1" }, + { MINIO_CONSOLE_MAPPED_PORT: "9000" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + await expect(minioSetup(inputAnswers)).rejects.toThrow( + "Port conflict detected: MinIO API and Console ports must be different", + ); + }); +}); diff --git a/test/setup/postgresSetup.test.ts b/test/setup/postgresSetup.test.ts new file mode 100644 index 0000000000..27fabb1614 --- /dev/null +++ b/test/setup/postgresSetup.test.ts @@ -0,0 +1,76 @@ +import dotenv from "dotenv"; +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { postgresSetup, setup } from "~/src/setup/setup"; + +vi.mock("inquirer"); + +describe("Setup -> postgresSetup", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetAllMocks(); + }); + + it("should prompt the user for Postgres configuration and update process.env", async () => { + const mockResponses = [ + { POSTGRES_DB: "mocked-db" }, + { POSTGRES_PASSWORD: "mocked-password" }, + { POSTGRES_USER: "mocked-user" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + const answers = await postgresSetup({}); + + const expectedEnv = { + POSTGRES_DB: "mocked-db", + POSTGRES_PASSWORD: "mocked-password", + POSTGRES_USER: "mocked-user", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(answers[key]).toBe(value); + } + }); + + it("should prompt extended Postgres fields when user chooses custom Postgres (CI=false)", async () => { + const mockResponses = [ + { envReconfigure: "true" }, + { CI: "false" }, + { useDefaultApi: "true" }, + { useDefaultMinio: "true" }, + { useDefaultCloudbeaver: "true" }, + { useDefaultPostgres: false }, + { POSTGRES_DB: "customDatabase" }, + { POSTGRES_MAPPED_HOST_IP: "1.2.3.4" }, + { POSTGRES_MAPPED_PORT: "5433" }, + { POSTGRES_PASSWORD: "myPassword" }, + { POSTGRES_USER: "myUser" }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@postgres.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const resp of mockResponses) { + promptMock.mockResolvedValueOnce(resp); + } + + await setup(); + dotenv.config({ path: ".env" }); + + const expectedEnv = { + POSTGRES_DB: "customDatabase", + POSTGRES_MAPPED_HOST_IP: "1.2.3.4", + POSTGRES_MAPPED_PORT: "5433", + POSTGRES_PASSWORD: "myPassword", + POSTGRES_USER: "myUser", + }; + for (const [key, value] of Object.entries(expectedEnv)) { + expect(process.env[key]).toBe(value); + } + }); +}); diff --git a/test/setup/setup.test.ts b/test/setup/setup.test.ts new file mode 100644 index 0000000000..9fd9458329 --- /dev/null +++ b/test/setup/setup.test.ts @@ -0,0 +1,157 @@ +import fs from "node:fs"; +import dotenv from "dotenv"; +import inquirer from "inquirer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setup } from "~/src/setup/setup"; +import * as SetupModule from "~/src/setup/setup"; + +vi.mock("inquirer"); +describe("Setup", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetAllMocks(); + }); + + it("should set up environment variables with default configuration when CI=false", async () => { + const mockResponses = [ + { envReconfigure: true }, + { CI: "false" }, + { useDefaultApi: "true" }, + { useDefaultMinio: "true" }, + { useDefaultCloudbeaver: "true" }, + { useDefaultPostgres: "true" }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@email.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + await setup(); + + const expectedEnv = { + API_BASE_URL: "http://127.0.0.1:4000", + API_HOST: "0.0.0.0", + API_PORT: "4000", + API_IS_APPLY_DRIZZLE_MIGRATIONS: "true", + API_JWT_EXPIRES_IN: "2592000000", + API_LOG_LEVEL: "info", + API_MINIO_ACCESS_KEY: "talawa", + API_MINIO_END_POINT: "minio", + API_MINIO_PORT: "9000", + API_MINIO_TEST_END_POINT: "minio-test", + API_MINIO_USE_SSL: "false", + API_POSTGRES_DATABASE: "talawa", + API_POSTGRES_HOST: "postgres", + API_POSTGRES_PORT: "5432", + API_POSTGRES_SSL_MODE: "false", + API_POSTGRES_TEST_HOST: "postgres-test", + API_POSTGRES_USER: "talawa", + CI: "false", + MINIO_ROOT_USER: "talawa", + CLOUDBEAVER_ADMIN_NAME: "talawa", + CLOUDBEAVER_MAPPED_HOST_IP: "127.0.0.1", + CLOUDBEAVER_MAPPED_PORT: "8978", + CLOUDBEAVER_SERVER_NAME: "Talawa CloudBeaver Server", + CLOUDBEAVER_SERVER_URL: "http://127.0.0.1:8978", + }; + + dotenv.config({ path: ".env" }); + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(process.env[key]).toBe(value); + } + }); + + it("should correctly set up environment variables when CI=true (skips CloudBeaver)", async () => { + const mockResponses = [ + { envReconfigure: true }, + { CI: "true" }, + { useDefaultApi: "true" }, + { useDefaultMinio: "true" }, + { useDefaultPostgres: "true" }, + { API_ADMINISTRATOR_USER_EMAIL_ADDRESS: "test@email.com" }, + ]; + + const promptMock = vi.spyOn(inquirer, "prompt"); + for (const response of mockResponses) { + promptMock.mockResolvedValueOnce(response); + } + + await setup(); + + const expectedEnv = { + API_BASE_URL: "http://127.0.0.1:4000", + API_HOST: "0.0.0.0", + API_PORT: "4000", + API_IS_APPLY_DRIZZLE_MIGRATIONS: "true", + API_IS_GRAPHIQL: "false", + API_IS_PINO_PRETTY: "false", + API_JWT_EXPIRES_IN: "2592000000", + API_LOG_LEVEL: "info", + API_MINIO_ACCESS_KEY: "talawa", + API_MINIO_END_POINT: "minio", + API_MINIO_PORT: "9000", + API_MINIO_SECRET_KEY: "password", + API_MINIO_TEST_END_POINT: "minio-test", + API_MINIO_USE_SSL: "false", + API_POSTGRES_DATABASE: "talawa", + API_POSTGRES_HOST: "postgres", + API_POSTGRES_PASSWORD: "password", + API_POSTGRES_PORT: "5432", + API_POSTGRES_SSL_MODE: "false", + API_POSTGRES_TEST_HOST: "postgres-test", + API_POSTGRES_USER: "talawa", + CI: "true", + MINIO_ROOT_PASSWORD: "password", + MINIO_ROOT_USER: "talawa", + }; + + for (const [key, value] of Object.entries(expectedEnv)) { + expect(process.env[key]).toBe(value); + } + }); + it("should restore .env from backup and exit when envReconfigure is false", async () => { + const processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + vi.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + envReconfigure: false, + }); + + await expect(SetupModule.setup()).rejects.toThrow("process.exit called"); + expect(processExitSpy).toHaveBeenCalledWith(0); + + processExitSpy.mockRestore(); + }); + + it("should restore .env on SIGINT (Ctrl+C) and exit with code 1", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const copyFileSpy = vi + .spyOn(fs, "copyFileSync") + .mockImplementation(() => {}); + const existsSyncSpy = vi.spyOn(fs, "existsSync").mockReturnValue(true); + + const processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await expect(async () => process.emit("SIGINT")).rejects.toThrow( + "process.exit called", + ); + expect(copyFileSpy).toHaveBeenCalledWith(".env.backup", ".env"); + expect(consoleLogSpy).toHaveBeenCalledWith( + "\nProcess interrupted! Undoing changes...", + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + copyFileSpy.mockRestore(); + existsSyncSpy.mockRestore(); + }); +}); diff --git a/test/setup/updateEnvVariable.test.ts b/test/setup/updateEnvVariable.test.ts new file mode 100644 index 0000000000..6315651b8b --- /dev/null +++ b/test/setup/updateEnvVariable.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { updateEnvVariable } from "~/src/setup/updateEnvVariable"; + +vi.mock("fs"); + +describe("updateEnvVariable", () => { + const envFileName = ".env"; + const backupFile = `${envFileName}.backup`; + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(fs, "existsSync").mockReturnValue(true); // Assume `.env` exists + }); + + it("should update an existing variable in .env", () => { + vi.spyOn(fs, "readFileSync").mockReturnValue("EXISTING_VAR=old_value"); + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}); + + updateEnvVariable({ EXISTING_VAR: "new_value" }); + + expect(writeSpy).toHaveBeenCalledWith( + envFileName, + expect.stringContaining("EXISTING_VAR=new_value"), + "utf8", + ); + expect(process.env.EXISTING_VAR).toBe("new_value"); + }); + + it("should add a new variable if it does not exist", () => { + vi.spyOn(fs, "readFileSync").mockReturnValue("EXISTING_VAR=old_value"); + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}); + + updateEnvVariable({ NEW_VAR: "new_value" }); + + expect(writeSpy).toHaveBeenCalledWith( + envFileName, + expect.stringContaining("NEW_VAR=new_value"), + "utf8", + ); + expect(process.env.NEW_VAR).toBe("new_value"); + }); + + it("should create a backup before updating .env", () => { + const copySpy = vi.spyOn(fs, "copyFileSync").mockImplementation(() => {}); + vi.spyOn(fs, "readFileSync").mockReturnValue("EXISTING_VAR=old_value"); + + updateEnvVariable({ EXISTING_VAR: "new_value" }); + + expect(copySpy).toHaveBeenCalledWith(envFileName, backupFile); + }); + + it("should restore from backup if an error occurs", () => { + const copySpy = vi.spyOn(fs, "copyFileSync").mockImplementation(() => {}); + vi.spyOn(fs, "readFileSync").mockReturnValue("EXISTING_VAR=old_value"); + vi.spyOn(fs, "writeFileSync").mockImplementation(() => { + throw new Error("Write failed"); + }); + + expect(() => updateEnvVariable({ EXISTING_VAR: "new_value" })).toThrow( + "Write failed", + ); + + expect(copySpy).toHaveBeenCalledWith(backupFile, envFileName); + }); + + it("should create .env if it does not exist", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}); + + updateEnvVariable({ NEW_VAR: "new_value" }); + + expect(writeSpy).toHaveBeenCalledWith( + envFileName, + expect.stringContaining("NEW_VAR=new_value"), + "utf8", + ); + expect(process.env.NEW_VAR).toBe("new_value"); + }); +});