diff --git a/README.md b/README.md index 059a480d..d50ae148 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Before running any services, create the local development environment from the * ./create_dev_envs.sh ``` -A `default.json` file containing environment variables will be provided to contributors and should be placed in `middlewareNode/config`. +Copy `middlewareNode/.env.example` to `middlewareNode/.env` and fill in your local values. A committed `middlewareNode/config/default.js` already provides safe, non-secret defaults, nothing needs to be handed to you separately. ## Running Each Service @@ -98,10 +98,13 @@ npm start The server typically runs on port 8000. You should see `"MongoDB Connected..."` when it starts successfully. -The following credentials can be used for testing mentor and student accounts: +To create local mentor and student demo accounts, run: -* **Mentor:** mentor / 123123123 -* **Student:** student / 123123123 +```bash +npm run seed:dev +``` + +This generates a brand-new random password for both accounts every time it runs and prints them to your terminal. Passwords are never committed or written to a file, and re-running the command rotates both passwords. This only works outside of a production environment. ### Main React Application (Frontend) diff --git a/chessServer/src/tests/validateEnvironment.test.js b/chessServer/src/tests/validateEnvironment.test.js new file mode 100644 index 00000000..66e6203c --- /dev/null +++ b/chessServer/src/tests/validateEnvironment.test.js @@ -0,0 +1,59 @@ +const validateEnvironment = require("../validateEnvironment"); + +const ORIGINAL_ENV = process.env; + +describe("validateEnvironment", () => { + let exitSpy; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); + }); + + const clearRequired = () => { + for (const name of ["MIDDLEWARE_URL", "CORS_ORIGIN", "ALLOWED_ORIGINS"]) { + delete process.env[name]; + } + }; + + it.each([undefined, "", "development", "test"])( + "skips validation when NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + } + ); + + it.each(["production", "staging", "qa", "prod"])( + "exits when required vars are missing and NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + } + ); + + it("passes in a non-local environment when everything is set", () => { + process.env.NODE_ENV = "staging"; + process.env.MIDDLEWARE_URL = "https://example.com/middleware"; + process.env.CORS_ORIGIN = "https://example.com"; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/chessServer/src/validateEnvironment.js b/chessServer/src/validateEnvironment.js index 14a9bdc7..bf9f3aa1 100644 --- a/chessServer/src/validateEnvironment.js +++ b/chessServer/src/validateEnvironment.js @@ -9,8 +9,18 @@ function hasCorsConfiguration() { ); } +// Local-only environments skip validation. Anything else (production, but +// also staging, qa, or a typo like "prod") is treated as a real deployment +// and must be fully configured, instead of silently booting with dev +// fallbacks such as middlewareNode's generated dev INDEX_KEY. +const LOCAL_ENVIRONMENTS = new Set(["", "development", "test"]); + +function isLocalEnvironment() { + return LOCAL_ENVIRONMENTS.has((process.env.NODE_ENV || "").trim()); +} + function validateEnvironment() { - if (process.env.NODE_ENV !== "production") { + if (isLocalEnvironment()) { return; } @@ -25,12 +35,12 @@ function validateEnvironment() { if (missing.length > 0) { console.error( - `[chessServer] Missing required production environment variables: ${missing.join(", ")}` + `[chessServer] Missing required environment variables for NODE_ENV=${process.env.NODE_ENV}: ${missing.join(", ")}` ); process.exit(1); } - console.log("[chessServer] Required production environment variables validated"); + console.log("[chessServer] Required environment variables validated"); } module.exports = validateEnvironment; diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 5a21e72a..c4581541 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -51,7 +51,7 @@ services: - "8080" middleware: - image: middlewarenode + image: middlewarenode:${TAG:-latest} container_name: middleware environment: - NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 8dcfd492..4518d4be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,21 @@ - services: + mongo: + image: mongo:6 + container_name: dev-mongo + ports: + - "27017:27017" + volumes: + - dev-mongo-data:/data/db + frontend: - build: ./react-ystemandchess + build: + context: ./react-ystemandchess + args: + REACT_APP_MIDDLEWARE_URL: http://localhost:8000 + REACT_APP_STOCKFISH_SERVER_URL: http://localhost:8080 + REACT_APP_CHESS_SERVER_URL: http://localhost:3001 + REACT_APP_CHESS_CLIENT_URL: http://localhost:3002 + REACT_APP_AGORA_APP_ID: "" container_name: frontend ports: - "3000:3000" @@ -12,6 +26,11 @@ services: container_name: chess-server ports: - "3001:3001" + environment: + - NODE_ENV=development + - PORT=3001 + - MIDDLEWARE_URL=http://middleware:8000 + - CORS_ORIGIN=http://localhost,http://localhost:3000,http://localhost:3002 restart: unless-stopped chess-client: @@ -27,9 +46,16 @@ services: ports: - "8000:8000" environment: - - NODE_ENV=production - volumes: - - ./middlewareNode/config/production.json:/app/config/production.json:ro + - NODE_ENV=development + - PORT=8000 + - MONGO_URI=mongodb://mongo:27017/ystem_dev + - INDEX_KEY=dev-index-key-replace-in-prod + - CORS_ORIGIN=http://localhost,http://localhost:3000 + - SESSION_SECRET=dev-secret-replace-in-prod + - ANALYTICS_RATE_LIMIT_MAX=100 + - LEADERBOARD_RATE_LIMIT_MAX=60 + depends_on: + - mongo restart: unless-stopped stockfish-server: @@ -37,4 +63,11 @@ services: container_name: stockfish-server ports: - "8080:8080" + environment: + - NODE_ENV=development + - PORT=8080 + - CORS_ORIGIN=http://localhost,http://localhost:3000 restart: unless-stopped + +volumes: + dev-mongo-data: diff --git a/middlewareNode/.env.example b/middlewareNode/.env.example index 6bd6f59a..c5ed04a1 100644 --- a/middlewareNode/.env.example +++ b/middlewareNode/.env.example @@ -60,5 +60,4 @@ PVP_WEIGHT_LOSS=0 ADMIN_USERNAME= # Present in config mapping but not currently consumed by middleware source -JWT_SECRET= EMAIL_PASS= diff --git a/middlewareNode/config/custom-environment-variables.json b/middlewareNode/config/custom-environment-variables.json index 73430b29..a4fa8ee1 100644 --- a/middlewareNode/config/custom-environment-variables.json +++ b/middlewareNode/config/custom-environment-variables.json @@ -1,6 +1,5 @@ { "mongoURI": "MONGO_URI", - "jwtSecret": "JWT_SECRET", "indexKey": "INDEX_KEY", "corsOptions": { "origin": "CORS_ORIGIN" }, diff --git a/middlewareNode/config/default.js b/middlewareNode/config/default.js index e051578b..7c9fe68f 100644 --- a/middlewareNode/config/default.js +++ b/middlewareNode/config/default.js @@ -6,8 +6,9 @@ const path = require("path"); // must have a value" in dev/test when INDEX_KEY isn't set — without checking // a fixed signing key into git history. custom-environment-variables.json // already maps INDEX_KEY onto this key, so any real deployment overrides it; -// validateEnvironment.js still requires INDEX_KEY in production, and runs -// before anything require()s this file, so this never touches disk in prod. +// validateEnvironment.js requires INDEX_KEY in every non-local environment +// (anything but NODE_ENV unset/development/test), and runs before anything +// require()s this file, so this never touches disk in a real deployment. // // Persisted (gitignored) rather than regenerated per boot, so dev JWTs and // password-reset links survive a nodemon restart instead of invalidating on @@ -44,7 +45,6 @@ if (!process.env.INDEX_KEY) { module.exports = { mongoURI: "", - jwtSecret: "", indexKey: devIndexKey, corsOptions: { diff --git a/middlewareNode/package.json b/middlewareNode/package.json index 1ca00490..481c4ec2 100644 --- a/middlewareNode/package.json +++ b/middlewareNode/package.json @@ -36,7 +36,8 @@ }, "scripts": { "start": "nodemon src/server.js", - "test": "jest --testPathPatterns=tests/" + "test": "jest --testPathPatterns=tests/", + "seed:dev": "node src/scripts/seedDevAccounts.js" }, "author": "", "license": "ISC", diff --git a/middlewareNode/src/config/db.js b/middlewareNode/src/config/db.js index 57cdbde9..4445b927 100644 --- a/middlewareNode/src/config/db.js +++ b/middlewareNode/src/config/db.js @@ -1,94 +1,35 @@ const mongoose = require("mongoose"); const config = require("config"); -const crypto = require("crypto"); +const { isLocalEnvironment } = require("./validateEnvironment"); + let db = config.get("mongoURI"); /** - * Seed initial test users into the database + * Seed non-credential mock data into the database: activity types, mock + * activities, mock lessons, mock puzzles. + * + * Deliberately does NOT create the "mentor" / "student" demo accounts. + * Those are credential-bearing and are handled by the opt-in + * `npm run seed:dev` script (src/scripts/seedDevAccounts.js) instead, + * which generates a fresh random password every time it runs rather than + * a static, hardcoded one. See that file for why. + * + * If a demo student account already exists (created via seed:dev), mock + * activity data gets attached to it. If not, that one step is skipped, + * everything else here still seeds normally. */ const seedTestUsers = async () => { try { const db = mongoose.connection.db; - // 1. Seed Users + // Look up the demo student account, if seed:dev has already created + // one, so mock activity data can be attached to it. This function + // never creates user accounts itself. const usersCollection = db.collection("users"); - const count = await usersCollection.countDocuments({ username: { $in: ["mentor", "student"] } }); - let studentId; - - const defaultLessonsCompleted = [ - "Piece Checkmate 1 Basic checkmates", - "Checkmate Pattern 1 Recognize the patterns", - "Checkmate Pattern 2 Recognize the patterns", - "Checkmate Pattern 3 Recognize the patterns", - "Checkmate Pattern 4 Recognize the patterns", - "Piece checkmates 2 Challenging checkmates", - "Knight and Bishop Mate interactive lesson", - "The Pin Pin it to win it", - "The Skewer Yum - Skewers!", - "The Fork Use the fork, Luke", - "Discovered Attacks Including discovered checks", - "Double Check A very powerfull tactic", - "Overloaded Pieces They have too much work", - "Zwischenzug In-between moves", - "X-Ray Attacking through an enemy piece", - "Zugzwang Being forced to move", - "Interference Interpose a piece to great effect", - "Greek Gift Study the greek gift scrifice", - "Deflection Distracting a defender", - "Attraction Lure a piece to bad square", - "Underpromotion Promote - but not to a queen!", - "Desperado A piece is lost, but it can still help", - "Counter Check Respond to a check with a check", - "Undermining Remove the defending piece", - "Clearance Get out of the way!", - "Key Squares Reach the key square", - "Opposition take the opposition", - "7th-Rank Rook Pawn Versus a Queen", - "7th-Rank Rook Pawn And Passive Rook vs Rook", - "Basic Rook Endgames Lucena and Philidor" - ].map(piece => ({ piece, lessonNumber: 0 })); - - if (count === 0) { - console.log("Seeding test users into the database..."); - const mentorPassword = crypto.createHash("sha384").update("123123123").digest("hex"); - const mentor = { - username: "mentor", - password: mentorPassword, - firstName: "Test", - lastName: "Mentor", - email: "mentor@test.com", - role: "mentor", - mentorshipUsername: "student", - accountCreatedAt: new Date().toLocaleString(), - timePlayed: 0 - }; - - const studentPassword = crypto.createHash("sha384").update("123123123").digest("hex"); - const student = { - username: "student", - password: studentPassword, - firstName: "Test", - lastName: "Student", - email: "student@test.com", - role: "student", - mentorshipUsername: "mentor", - accountCreatedAt: new Date().toLocaleString(), - timePlayed: 0, - lessonsCompleted: defaultLessonsCompleted - }; - - const mentorResult = await usersCollection.insertOne(mentor); - const studentResult = await usersCollection.insertOne(student); - studentId = studentResult.insertedId; - console.log("✅ Test users seeded successfully!"); - } else { - const studentDoc = await usersCollection.findOne({ username: "student" }); - if (studentDoc) { - studentId = studentDoc._id; - } - } + const studentDoc = await usersCollection.findOne({ username: "student" }); + const studentId = studentDoc ? studentDoc._id : undefined; - // 2. Seed activityTypes + // 1. Seed activityTypes const activityTypesCollection = db.collection("activityTypes"); const activityTypesCount = await activityTypesCollection.countDocuments({}); if (activityTypesCount === 0) { @@ -104,7 +45,7 @@ const seedTestUsers = async () => { console.log("✅ Activity types seeded successfully!"); } - // 3. Seed activities for student + // 2. Seed activities for student if (studentId) { const activitiesCollection = db.collection("activities"); const activitiesCount = await activitiesCollection.countDocuments({ userId: studentId }); @@ -125,7 +66,7 @@ const seedTestUsers = async () => { } } - // 4. Seed newLessons + // 3. Seed newLessons const lessonsCollection = db.collection("newLessons"); const lessonsCount = await lessonsCollection.countDocuments({}); if (lessonsCount === 0) { @@ -186,7 +127,7 @@ const seedTestUsers = async () => { console.log("✅ Mock lessons seeded successfully!"); } - // 5. Seed puzzles + // 4. Seed puzzles const puzzlesCollection = db.collection("puzzles"); const puzzlesCount = await puzzlesCollection.countDocuments({}); if (puzzlesCount === 0) { @@ -249,7 +190,10 @@ async function ensureIndexes() { } } -const IS_PRODUCTION = process.env.NODE_ENV === "production"; +// Any non-local NODE_ENV (production, but also staging, qa, or a typo like +// "prod") is a real deployment: no mock-data seeding, no in-memory fallback, +// and the longer connection timeout. Same rule validateEnvironment.js uses. +const IS_DEPLOYED = !isLocalEnvironment(); const connectDB = async () => { try { @@ -270,24 +214,24 @@ const connectDB = async () => { await mongoose.connect(db, { useNewUrlParser: true, useUnifiedTopology: true, - // Production has no fallback (a failure here calls process.exit(1)), + // Deployed environments have no fallback (a failure here calls process.exit(1)), // so it gets a longer timeout to ride out a network blip or an Atlas // cold-start during deploy. Dev/test fail fast to the in-memory // fallback below. - serverSelectionTimeoutMS: IS_PRODUCTION ? 10000 : 1000, + serverSelectionTimeoutMS: IS_DEPLOYED ? 10000 : 1000, }); console.log("MongoDB Connected..."); await ensureIndexes(); - if (!IS_PRODUCTION) { + if (!IS_DEPLOYED) { await seedTestUsers(); } } catch (err) { console.warn(`Connection to configured MongoDB failed: ${err.message}`); - if (IS_PRODUCTION) { + if (IS_DEPLOYED) { console.error( - "Refusing to fall back to in-memory MongoDB in production. Exiting." + `Refusing to fall back to in-memory MongoDB with NODE_ENV=${process.env.NODE_ENV}. Exiting.` ); process.exit(1); } diff --git a/middlewareNode/src/config/validateEnvironment.js b/middlewareNode/src/config/validateEnvironment.js index c6eb8267..bcc2321a 100644 --- a/middlewareNode/src/config/validateEnvironment.js +++ b/middlewareNode/src/config/validateEnvironment.js @@ -5,8 +5,18 @@ const REQUIRED_PRODUCTION_VARS = [ "SESSION_SECRET", ]; +// Local-only environments skip validation. Anything else (production, but +// also staging, qa, or a typo like "prod") is treated as a real deployment +// and must be fully configured, instead of silently booting with dev +// fallbacks such as middlewareNode's generated dev INDEX_KEY. +const LOCAL_ENVIRONMENTS = new Set(["", "development", "test"]); + +function isLocalEnvironment() { + return LOCAL_ENVIRONMENTS.has((process.env.NODE_ENV || "").trim()); +} + function validateEnvironment() { - if (process.env.NODE_ENV !== "production") { + if (isLocalEnvironment()) { return; } @@ -17,12 +27,13 @@ function validateEnvironment() { if (missing.length > 0) { console.error( - `[boot] Missing required production environment variables: ${missing.join(", ")}` + `[boot] Missing required environment variables for NODE_ENV=${process.env.NODE_ENV}: ${missing.join(", ")}` ); process.exit(1); } - console.log("[boot] Required production environment variables validated"); + console.log("[boot] Required environment variables validated"); } module.exports = validateEnvironment; +module.exports.isLocalEnvironment = isLocalEnvironment; diff --git a/middlewareNode/src/routes/chat.js b/middlewareNode/src/routes/chat.js index 9d8618cf..9df28487 100644 --- a/middlewareNode/src/routes/chat.js +++ b/middlewareNode/src/routes/chat.js @@ -12,6 +12,12 @@ const { detectCrisis, getCrisisResponse } = require('../utils/guardrails'); const passport = require('passport'); const Guardrail = require('../models/Guardrail'); const requireAuth = require('../middleware/requireAuth'); +const { isLocalEnvironment } = require('../config/validateEnvironment'); + +// Same gate as config/db.js's seedTestUsers: config-template seeding, not +// credential-bearing, but still a side effect on import that should only +// run locally, not in production, staging, qa, or any other deployment. +const IS_DEPLOYED = !isLocalEnvironment(); // Ensure logs directory exists const LOGS_DIR = path.join(__dirname, '../../logs'); @@ -97,7 +103,9 @@ const seedDefaultTemplates = async () => { console.error('Error seeding CoachTemplates:', error.message); } }; -seedDefaultTemplates(); +if (!IS_DEPLOYED) { + seedDefaultTemplates(); +} // Seeding default guardrails in MongoDB on start const seedDefaultGuardrail = async () => { @@ -113,7 +121,9 @@ const seedDefaultGuardrail = async () => { console.error('Error seeding Guardrails:', error.message); } }; -seedDefaultGuardrail(); +if (!IS_DEPLOYED) { + seedDefaultGuardrail(); +} // Middleware to authorize Tutors or Admins const authorizeTutorAdmin = (req, res, next) => { diff --git a/middlewareNode/src/scripts/seedDevAccounts.js b/middlewareNode/src/scripts/seedDevAccounts.js new file mode 100644 index 00000000..db514fb0 --- /dev/null +++ b/middlewareNode/src/scripts/seedDevAccounts.js @@ -0,0 +1,127 @@ +/** + * Dev-Only Demo Account Seeder + * + * Creates (or resets) the "mentor" and "student" demo accounts used for + * manual local development, so a developer can open the app and click + * around as either role without running the signup flow first. + * + * This replaces the old approach of a fixed, hardcoded password + * (`123123123`) that was created automatically on every server start and + * published in the README. That's what let it end up guessable and public + * for years. This script is the opposite on every axis: + * + * - Opt-in only. Nothing calls this automatically. Run it yourself: + * npm run seed:dev + * - Every run generates a brand-new random password for both accounts, + * whether the account already exists or not. There is no static value + * to leak, and an old password stops working the moment you rotate it + * by running this again. + * - The password is printed to the terminal only. It is never written to + * a file, never logged anywhere persistent, and never committed. + * - Refuses to run at all outside local development (any NODE_ENV other + * than unset/development/test), using the same isLocalEnvironment() + * check as src/config/validateEnvironment.js. + * + * Usage: + * npm run seed:dev + * (or, directly: node src/scripts/seedDevAccounts.js) + * + * Requires MONGO_URI (or a local config/default.json) pointing at a real, + * disposable development database. Never point this at production data. + */ + +require("dotenv").config(); +const mongoose = require("mongoose"); +const config = require("config"); +const crypto = require("crypto"); +const Users = require("../models/users"); +const { isLocalEnvironment } = require("../config/validateEnvironment"); + +if (!isLocalEnvironment()) { + console.error( + `Refusing to seed demo accounts with NODE_ENV=${process.env.NODE_ENV}. This script is for local development only.` + ); + process.exit(1); +} + +/** + * 16 bytes of randomness, base64url-encoded so it's safe to read off a + * terminal or type back in by hand if needed. + */ +function generatePassword() { + return crypto.randomBytes(16).toString("base64url"); +} + +/** + * Matches the hashing scheme every login/signup route already uses + * (routes/auth.js, routes/users.js). This script's job is to stay + * consistent with that scheme, not to change it. + */ +function hashPassword(plaintext) { + return crypto.createHash("sha384").update(plaintext).digest("hex"); +} + +async function upsertDemoAccount({ username, role, mentorshipUsername, firstName, lastName, email }) { + const plaintextPassword = generatePassword(); + const hashedPassword = hashPassword(plaintextPassword); + + await Users.findOneAndUpdate( + { username }, + { + $set: { + password: hashedPassword, + role, + mentorshipUsername, + firstName, + lastName, + email, + }, + $setOnInsert: { + accountCreatedAt: new Date().toLocaleString(), + timePlayed: 0, + }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return plaintextPassword; +} + +async function run() { + await mongoose.connect(config.get("mongoURI")); + console.log("Connected to MongoDB"); + + const mentorPassword = await upsertDemoAccount({ + username: "mentor", + role: "mentor", + mentorshipUsername: "student", + firstName: "Demo", + lastName: "Mentor", + email: "demo-mentor@ystemandchess.local", + }); + + const studentPassword = await upsertDemoAccount({ + username: "student", + role: "student", + mentorshipUsername: "mentor", + firstName: "Demo", + lastName: "Student", + email: "demo-student@ystemandchess.local", + }); + + await mongoose.disconnect(); + + console.log(""); + console.log("Demo accounts ready. These passwords are freshly generated for this run only,"); + console.log("they are shown here and nowhere else:"); + console.log(""); + console.log(` mentor / ${mentorPassword}`); + console.log(` student / ${studentPassword}`); + console.log(""); + console.log("Run `npm run seed:dev` again any time to rotate both passwords."); +} + +run().catch((err) => { + console.error("Seeding demo accounts failed:", err.message); + process.exit(1); +}); diff --git a/middlewareNode/src/server.js b/middlewareNode/src/server.js index 91c651ef..df36e4e5 100644 --- a/middlewareNode/src/server.js +++ b/middlewareNode/src/server.js @@ -115,7 +115,7 @@ app.use( session({ secret: process.env.SESSION_SECRET || - (process.env.NODE_ENV !== "production" + (validateEnvironment.isLocalEnvironment() ? "dev-secret-change-in-prod" : undefined), resave: false, diff --git a/middlewareNode/tests/chatSeeding.regression.test.js b/middlewareNode/tests/chatSeeding.regression.test.js new file mode 100644 index 00000000..0d8b4a1c --- /dev/null +++ b/middlewareNode/tests/chatSeeding.regression.test.js @@ -0,0 +1,64 @@ +/** + * Regression test — chat.js's default CoachTemplate/Guardrail seeding + * must never run in a deployed environment (production, staging, qa, or + * any other non-local NODE_ENV). Issue 5, Phase 2/6 (same gate + * pattern as db.js's seedTestUsers, applied here for consistency). + */ + +jest.mock("../src/models/ChatSession"); +jest.mock("../src/models/ChatMessage"); +jest.mock("../src/models/CoachTemplate"); +jest.mock("../src/models/Guardrail"); +// Unrelated to seeding — this test resetModules()'s chat.js twice, and the +// real chatService kicks off a genuine (fire-and-forget) NLP training run +// on each require that can otherwise still be in flight after the test +// file finishes, producing spurious "log after tests are done" noise. +jest.mock("../src/utils/chatService"); +jest.mock("passport", () => ({ + authenticate: jest.fn(() => (req, res, next) => next()), +})); + +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + +afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + jest.resetModules(); + jest.clearAllMocks(); +}); + +// seedDefaultTemplates/seedDefaultGuardrail are fire-and-forget promises +// kicked off at module load — flush the microtask queue before asserting +// so we're not checking before an (incorrectly) fired call would land. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("chat.js seeding — deployed-environment gate", () => { + test.each(["production", "staging", "qa", "prod"])("does NOT seed default templates or guardrails when NODE_ENV=%s", async (env) => { + process.env.NODE_ENV = env; + jest.resetModules(); + + const CoachTemplate = require("../src/models/CoachTemplate"); + const Guardrail = require("../src/models/Guardrail"); + + require("../src/routes/chat"); + await flush(); + await flush(); + + expect(CoachTemplate.findOneAndUpdate).not.toHaveBeenCalled(); + expect(Guardrail.countDocuments).not.toHaveBeenCalled(); + }); + + test.each(["test", "development"])("DOES seed default templates and guardrails when NODE_ENV=%s (gate isn't inverted)", async (env) => { + process.env.NODE_ENV = env; + jest.resetModules(); + + const CoachTemplate = require("../src/models/CoachTemplate"); + const Guardrail = require("../src/models/Guardrail"); + + require("../src/routes/chat"); + await flush(); + await flush(); + + expect(CoachTemplate.findOneAndUpdate).toHaveBeenCalled(); + expect(Guardrail.countDocuments).toHaveBeenCalled(); + }); +}); diff --git a/middlewareNode/tests/dbSeeding.regression.test.js b/middlewareNode/tests/dbSeeding.regression.test.js new file mode 100644 index 00000000..8902e6a7 --- /dev/null +++ b/middlewareNode/tests/dbSeeding.regression.test.js @@ -0,0 +1,80 @@ +/** + * Regression test — db.js's mock-data seeding (seedTestUsers) must never + * run in a deployed environment (production, staging, qa, or any other + * non-local NODE_ENV). Issue 5, Phase 6. + * + * mongoose is fully mocked so this runs without a real database; the only + * thing under test is whether the seeding-specific collections get + * touched at all, not what gets written to them. + */ + +jest.mock("mongoose", () => { + const collectionsRequested = []; + const fakeCollection = { + createIndex: jest.fn().mockResolvedValue(undefined), + countDocuments: jest.fn().mockResolvedValue(0), + // Simulates a demo student account already existing (e.g. created via + // seed:dev), so the conditional activities-seeding branch is actually + // exercised by these tests too, not just the unconditional collections. + findOne: jest.fn().mockResolvedValue({ _id: "mock-student-id" }), + insertOne: jest.fn().mockResolvedValue({ insertedId: "mockid" }), + insertMany: jest.fn().mockResolvedValue({}), + }; + // seedTestUsers reaches collections via mongoose.connection.db.collection(...) + const dbCollectionSpy = jest.fn((name) => { + collectionsRequested.push(name); + return fakeCollection; + }); + return { + connect: jest.fn().mockResolvedValue(undefined), + connection: { + db: { collection: dbCollectionSpy }, + // ensureIndexes reaches collections via mongoose.connection.collection(...) + // directly — kept separate so its index-creation calls (which run + // regardless of environment) don't pollute the seeding assertions below. + collection: jest.fn(() => fakeCollection), + }, + __collectionsRequested: collectionsRequested, + }; +}); + +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + +afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + jest.resetModules(); +}); + +const SEED_ONLY_COLLECTIONS = ["activityTypes", "activities", "newLessons", "puzzles"]; + +describe("db.js seeding — deployed-environment gate", () => { + test.each(["production", "staging", "qa", "prod"])("does NOT seed mock data when NODE_ENV=%s", async (env) => { + process.env.NODE_ENV = env; + jest.resetModules(); + + const mongoose = require("mongoose"); + const connectDB = require("../src/config/db"); + await connectDB(); + + const requested = mongoose.__collectionsRequested; + for (const name of SEED_ONLY_COLLECTIONS) { + expect(requested).not.toContain(name); + } + expect(mongoose.connect.mock.calls[0][1].serverSelectionTimeoutMS).toBe(10000); + }); + + test.each(["test", "development"])("DOES seed mock data when NODE_ENV=%s (gate isn't inverted)", async (env) => { + process.env.NODE_ENV = env; + jest.resetModules(); + + const mongoose = require("mongoose"); + const connectDB = require("../src/config/db"); + await connectDB(); + + const requested = mongoose.__collectionsRequested; + for (const name of SEED_ONLY_COLLECTIONS) { + expect(requested).toContain(name); + } + expect(mongoose.connect.mock.calls[0][1].serverSelectionTimeoutMS).toBe(1000); + }); +}); diff --git a/middlewareNode/tests/validateEnvironment.test.js b/middlewareNode/tests/validateEnvironment.test.js new file mode 100644 index 00000000..02ee65b2 --- /dev/null +++ b/middlewareNode/tests/validateEnvironment.test.js @@ -0,0 +1,61 @@ +const validateEnvironment = require("../src/config/validateEnvironment"); + +const ORIGINAL_ENV = process.env; + +describe("validateEnvironment", () => { + let exitSpy; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); + }); + + const clearRequired = () => { + for (const name of ["MONGO_URI", "INDEX_KEY", "CORS_ORIGIN", "SESSION_SECRET"]) { + delete process.env[name]; + } + }; + + it.each([undefined, "", "development", "test"])( + "skips validation when NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + } + ); + + it.each(["production", "staging", "qa", "prod"])( + "exits when required vars are missing and NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + } + ); + + it("passes in a non-local environment when everything is set", () => { + process.env.NODE_ENV = "staging"; + process.env.MONGO_URI = "mongodb://example/db"; + process.env.INDEX_KEY = "k"; + process.env.CORS_ORIGIN = "https://example.com"; + process.env.SESSION_SECRET = "s"; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/stockfishServer/src/tests/validateEnvironment.test.js b/stockfishServer/src/tests/validateEnvironment.test.js new file mode 100644 index 00000000..6539feaf --- /dev/null +++ b/stockfishServer/src/tests/validateEnvironment.test.js @@ -0,0 +1,58 @@ +const validateEnvironment = require("../validateEnvironment"); + +const ORIGINAL_ENV = process.env; + +describe("validateEnvironment", () => { + let exitSpy; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); + }); + + const clearRequired = () => { + for (const name of ["CORS_ORIGIN", "ALLOWED_ORIGINS"]) { + delete process.env[name]; + } + }; + + it.each([undefined, "", "development", "test"])( + "skips validation when NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + } + ); + + it.each(["production", "staging", "qa", "prod"])( + "exits when required vars are missing and NODE_ENV=%p", + (nodeEnv) => { + clearRequired(); + process.env.NODE_ENV = nodeEnv; + + expect(() => validateEnvironment()).toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + } + ); + + it("passes in a non-local environment when everything is set", () => { + process.env.NODE_ENV = "staging"; + process.env.CORS_ORIGIN = "https://example.com"; + + expect(() => validateEnvironment()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/stockfishServer/src/validateEnvironment.js b/stockfishServer/src/validateEnvironment.js index 4c94408f..ddb5770e 100644 --- a/stockfishServer/src/validateEnvironment.js +++ b/stockfishServer/src/validateEnvironment.js @@ -5,8 +5,18 @@ function hasCorsConfiguration() { ); } +// Local-only environments skip validation. Anything else (production, but +// also staging, qa, or a typo like "prod") is treated as a real deployment +// and must be fully configured, instead of silently booting with dev +// fallbacks such as middlewareNode's generated dev INDEX_KEY. +const LOCAL_ENVIRONMENTS = new Set(["", "development", "test"]); + +function isLocalEnvironment() { + return LOCAL_ENVIRONMENTS.has((process.env.NODE_ENV || "").trim()); +} + function validateEnvironment() { - if (process.env.NODE_ENV !== "production") { + if (isLocalEnvironment()) { return; } @@ -18,12 +28,12 @@ function validateEnvironment() { if (missing.length > 0) { console.error( - `[stockfishServer] Missing required production environment variables: ${missing.join(", ")}` + `[stockfishServer] Missing required environment variables for NODE_ENV=${process.env.NODE_ENV}: ${missing.join(", ")}` ); process.exit(1); } - console.log("[stockfishServer] Required production environment variables validated"); + console.log("[stockfishServer] Required environment variables validated"); } module.exports = validateEnvironment;