diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..82180c68 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# URL of the backend API (no trailing slash). +# Must match the origin the backend server listens on. +VITE_BACKEND_URL=http://localhost:5000 diff --git a/README.md b/README.md index d330bd24..b16c9995 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,27 @@ Install backend dependencies: npm install ``` -Start the backend server: +3. Configure environment variables + Copy the example files and fill in your values: + ```bash + # Frontend (.env in the repo root) + cp .env.example .env + + # Backend (.env inside backend/) + cp backend/.env.example backend/.env + ``` + + Key variables to set: + + | Variable | Where | Description | + |---|---|---| + | `VITE_BACKEND_URL` | root `.env` | URL of the backend (default: `http://localhost:5000`) | + | `MONGO_URI` | `backend/.env` | MongoDB connection string | + | `SESSION_SECRET` | `backend/.env` | Long random string used to sign session cookies | + | `FRONTEND_ORIGIN` | `backend/.env` | URL of the frontend — restricts CORS. **Required in production.** Defaults to `http://localhost:5173` in development. | + +4. Run the frontend ```bash npm run dev ``` @@ -115,18 +134,11 @@ The backend server will run on: http://localhost:5000 ``` ---- - -# 🐳 Docker Development Workflow - -The project includes Docker configurations for both development and production environments. - -## 📦 Development Environment - -Run the complete development environment using Docker: - +5. Run the backend ```bash -npm run docker:dev +$ cd backend +$ npm i +$ npm start ``` This command: diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..a46d9911 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,37 @@ +# --------------------------------------------------------------- +# Server +# --------------------------------------------------------------- +PORT=5000 +NODE_ENV=development + +# --------------------------------------------------------------- +# MongoDB +# --------------------------------------------------------------- +MONGO_URI=mongodb://127.0.0.1:27017/github_tracker + +# --------------------------------------------------------------- +# Session +# Generate a long random string for production, e.g.: +# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +# --------------------------------------------------------------- +SESSION_SECRET=replace-with-a-long-random-string + +# --------------------------------------------------------------- +# CORS — Frontend origin allowlist +# +# Set this to the exact URL of your frontend (no trailing slash). +# REQUIRED in production: the server will refuse to start without it. +# In development, the server defaults to http://localhost:5173 when +# this variable is not set. +# +# Examples: +# Development : FRONTEND_ORIGIN=http://localhost:5173 +# Production : FRONTEND_ORIGIN=https://app.example.com +# --------------------------------------------------------------- +FRONTEND_ORIGIN=http://localhost:5173 + +# --------------------------------------------------------------- +# Logging +# Accepted values: error | warn | info | debug +# --------------------------------------------------------------- +LOG_LEVEL=debug diff --git a/backend/config/validateEnv.js b/backend/config/validateEnv.js new file mode 100644 index 00000000..fde3d9a7 --- /dev/null +++ b/backend/config/validateEnv.js @@ -0,0 +1,15 @@ +/** + * Validates required environment variables before the server starts. + * Throws so callers can decide whether to log + exit or handle otherwise, + * which keeps the logic unit-testable without spawning child processes. + */ +function validateEnv() { + if (process.env.NODE_ENV === 'production' && !process.env.FRONTEND_ORIGIN) { + throw new Error( + 'FRONTEND_ORIGIN environment variable is required in production. ' + + 'Set it to the URL of your frontend (e.g., https://app.example.com).' + ); + } +} + +module.exports = { validateEnv }; diff --git a/backend/package.json b/backend/package.json index 74ab9dd7..747482ec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,8 @@ "zod": "^4.4.3" }, "devDependencies": { - "nodemon": "^3.1.9" + "jasmine": "^5.0.0", + "nodemon": "^3.1.9", + "supertest": "^7.0.0" } } diff --git a/backend/server.js b/backend/server.js index 48d6ccfb..c7fa106c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,32 +6,62 @@ const bodyParser = require('body-parser'); require('dotenv').config(); const cors = require('cors'); +const { validateEnv } = require('./config/validateEnv'); +const logger = require('./logger'); + +// Fail fast in production when required env vars are absent. +try { + validateEnv(); +} catch (err) { + logger.error(`[FATAL] ${err.message}`); + process.exit(1); +} + // Passport configuration require('./config/passportConfig'); -const logger = require('./logger'); - const app = express(); -// CORS configuration -const allowedOrigins = ['http://localhost:5173', 'https://github-spy.etlify.app']; +// In development, fall back to localhost:5173 if FRONTEND_ORIGIN is not set so +// that contributors can run the stack without a full .env file. +const corsOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; + +if (!process.env.FRONTEND_ORIGIN) { + logger.warn( + 'FRONTEND_ORIGIN is not set; defaulting to http://localhost:5173. ' + + 'Set this variable in production.' + ); +} + +// CORS — explicit allowlist with credentials support. +// A function-based origin is required so that the header is only set (and +// reflected) for allowed origins; a static string would send the header on +// every response regardless of the requesting origin. app.use(cors({ - origin: function (origin, callback) { - if (!origin || allowedOrigins.indexOf(origin) !== -1) { - callback(null, true); - } else{ - callback(new Error('Blocked by CORS policy')); - } - }, - credentials: true + origin: (requestOrigin, callback) => { + // Allow same-origin requests (no Origin header) and the configured origin. + if (!requestOrigin || requestOrigin === corsOrigin) { + return callback(null, true); + } + callback(null, false); + }, + credentials: true, + methods: ['GET', 'POST'], + allowedHeaders: ['Content-Type'], })); // Middleware app.use(bodyParser.json()); app.use(session({ - secret: process.env.SESSION_SECRET, - resave: false, - saveUninitialized: false, + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + // Only transmit the cookie over HTTPS in production. + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + }, })); app.use(passport.initialize()); app.use(passport.session()); @@ -42,12 +72,10 @@ app.use('/api/auth', authRoutes); // Connect to MongoDB mongoose.connect(process.env.MONGO_URI, {}).then(() => { - logger.info('Connected to MongoDB'); - - const PORT = process.env.PORT || 5000; - app.listen(PORT, () => { - logger.info(`Server running on port ${PORT}`); - }); + logger.info('Connected to MongoDB'); + app.listen(process.env.PORT, () => { + logger.info(`Server running on port ${process.env.PORT}`); + }); }).catch((err) => { - logger.error('MongoDB connection error', err); + logger.error('MongoDB connection error', err); }); diff --git a/package.json b/package.json index 5d166404..e29375a1 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "autoprefixer": "^10.4.20", "bcryptjs": "^3.0.3", + "cors": "^2.8.5", "eslint": "^9.13.0", "eslint-plugin-react": "^7.37.2", diff --git a/spec/auth.routes.spec.cjs b/spec/auth.routes.spec.cjs index 2b29c03a..a30d723c 100644 --- a/spec/auth.routes.spec.cjs +++ b/spec/auth.routes.spec.cjs @@ -1,16 +1,45 @@ -const mongoose = require('mongoose'); const express = require('express'); const request = require('supertest'); -const session = require('express-session'); -const passport = require('passport'); +const cors = require('cors'); -const User = require('../backend/models/User'); +// All backend modules are resolved from the backend directory so the test app +// and the application code share a single instance of each module — avoids +// connection-state mismatches that arise when backend/node_modules is present +// alongside the root node_modules. +const backendPath = [`${__dirname}/../backend`]; + +const mongoose = require(require.resolve('mongoose', { paths: backendPath })); +const session = require(require.resolve('express-session', { paths: backendPath })); +const passport = require(require.resolve('passport', { paths: backendPath })); + +const User = require('../backend/models/User'); const authRoutes = require('../backend/routes/auth'); +const { validateEnv } = require('../backend/config/validateEnv'); + +// A fixed allowed origin used throughout the test suite. +const ALLOWED_ORIGIN = 'http://localhost:5173'; + +// Password satisfying the signup Zod schema (uppercase + lowercase + digit + special). +const VALID_PASSWORD = 'TestPass1!'; -// Create test app function createTestApp() { const app = express(); + // Mirror the production CORS config: function-based origin so the header is + // only reflected for the configured origin, absent for all others. + const allowedOrigin = process.env.FRONTEND_ORIGIN || ALLOWED_ORIGIN; + app.use(cors({ + origin: (requestOrigin, callback) => { + if (!requestOrigin || requestOrigin === allowedOrigin) { + return callback(null, true); + } + callback(null, false); + }, + credentials: true, + methods: ['GET', 'POST'], + allowedHeaders: ['Content-Type'], + })); + app.use(express.json()); app.use( @@ -18,13 +47,15 @@ function createTestApp() { secret: 'test-secret', resave: false, saveUninitialized: false, + // Mirror production cookie options; secure:false is correct for HTTP tests. + cookie: { httpOnly: true, secure: false, sameSite: 'strict' }, }) ); app.use(passport.initialize()); app.use(passport.session()); - // Load passport config AFTER initializing passport + // Load passport config AFTER initializing passport. require('../backend/config/passportConfig'); app.use('/auth', authRoutes); @@ -32,10 +63,16 @@ function createTestApp() { return app; } -describe('Auth Routes', () => { +// --------------------------------------------------------------------------- +// Integration tests that require a running MongoDB instance. +// All MongoDB-dependent suites live inside one outer describe so they share +// a single connection, unaffected by Jasmine's random suite ordering. +// --------------------------------------------------------------------------- +describe('Backend auth integration', () => { let app; beforeAll(async () => { + process.env.FRONTEND_ORIGIN = ALLOWED_ORIGIN; await mongoose.connect('mongodb://127.0.0.1:27017/github_tracker_test'); app = createTestApp(); }); @@ -52,96 +89,259 @@ describe('Auth Routes', () => { }); // ---------------- SIGNUP ---------------- - it('should sign up a new user', async () => { - const res = await request(app) - .post('/auth/signup') - .send({ + describe('Auth Routes', () => { + it('should sign up a new user', async () => { + const res = await request(app) + .post('/auth/signup') + .send({ + username: 'testuser', + email: 'test@example.com', + password: VALID_PASSWORD, + }); + + expect(res.status).toBe(201); + expect(res.body.message).toBe('User created successfully'); + + const user = await User.findOne({ email: 'test@example.com' }); + expect(user).toBeTruthy(); + }); + + it('should not sign up a user with an existing email', async () => { + await User.create({ username: 'testuser', email: 'test@example.com', - password: 'password123', + password: VALID_PASSWORD, }); - expect(res.status).toBe(201); - expect(res.body.message).toBe('User created successfully'); + const res = await request(app) + .post('/auth/signup') + .send({ + username: 'testuser2', + email: 'test@example.com', + password: VALID_PASSWORD, + }); - const user = await User.findOne({ email: 'test@example.com' }); - expect(user).toBeTruthy(); - }); + expect(res.status).toBe(400); + expect(res.body.message).toBe('User already exists'); + }); - it('should not sign up a user with existing email', async () => { - await User.create({ - username: 'testuser', - email: 'test@example.com', - password: 'password123', + // ---------------- LOGIN ---------------- + it('should login a user with correct credentials', async () => { + await User.create({ + username: 'testuser', + email: 'test@example.com', + password: VALID_PASSWORD, + }); + + const agent = request.agent(app); + + const res = await agent.post('/auth/login').send({ + email: 'test@example.com', + password: VALID_PASSWORD, + }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Login successful'); + expect(res.body.user.email).toBe('test@example.com'); }); - const res = await request(app) - .post('/auth/signup') - .send({ - username: 'testuser2', + it('should reject login with the wrong password', async () => { + await User.create({ + username: 'testuser', email: 'test@example.com', - password: 'password456', + password: VALID_PASSWORD, }); - expect(res.status).toBe(400); - expect(res.body.message).toBe('User already exists'); + const agent = request.agent(app); + + const res = await agent.post('/auth/login').send({ + email: 'test@example.com', + password: 'wrongpassword', + }); + + expect(res.status).toBe(401); + }); + + // ---------------- LOGOUT ---------------- + it('should logout a logged-in user', async () => { + const agent = request.agent(app); + + await agent.post('/auth/signup').send({ + username: 'testuser', + email: 'test@example.com', + password: VALID_PASSWORD, + }); + + await agent.post('/auth/login').send({ + email: 'test@example.com', + password: VALID_PASSWORD, + }); + + const res = await agent.get('/auth/logout'); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Logged out successfully'); + }); }); - // ---------------- LOGIN ---------------- - it('should login a user with correct credentials', async () => { - await User.create({ - username: 'testuser', - email: 'test@example.com', - password: 'password123', + // ---------------- CORS BEHAVIOUR ---------------- + describe('CORS behaviour', () => { + it('should include Access-Control-Allow-Origin for the configured origin', async () => { + const res = await request(app) + .get('/auth/logout') + .set('Origin', ALLOWED_ORIGIN); + + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); }); - const agent = request.agent(app); + it('should include Access-Control-Allow-Credentials: true for the allowed origin', async () => { + const res = await request(app) + .get('/auth/logout') + .set('Origin', ALLOWED_ORIGIN); - const res = await agent.post('/auth/login').send({ - email: 'test@example.com', - password: 'password123', + expect(res.headers['access-control-allow-credentials']).toBe('true'); }); - expect(res.status).toBe(200); - expect(res.body.message).toBe('Login successful'); - expect(res.body.user.email).toBe('test@example.com'); - }); + it('should not set Access-Control-Allow-Origin for an unlisted origin', async () => { + const res = await request(app) + .get('/auth/logout') + .set('Origin', 'http://evil.example.com'); - it('should not login a user with wrong password', async () => { - await User.create({ - username: 'testuser', - email: 'test@example.com', - password: 'password123', + expect(res.headers['access-control-allow-origin']).toBeUndefined(); }); - const agent = request.agent(app); + it('should respond to a preflight OPTIONS request from the allowed origin', async () => { + const res = await request(app) + .options('/auth/login') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'Content-Type'); - const res = await agent.post('/auth/login').send({ - email: 'test@example.com', - password: 'wrongpassword', + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + // 204 is the standard success status for preflight responses. + expect([200, 204]).toContain(res.status); }); - expect(res.status).toBe(401); + it('should include CORS headers on a credentialed login request from the allowed origin', async () => { + await User.create({ + username: 'corsuser', + email: 'cors@example.com', + password: VALID_PASSWORD, + }); + + const res = await request(app) + .post('/auth/login') + .set('Origin', ALLOWED_ORIGIN) + .send({ email: 'cors@example.com', password: VALID_PASSWORD }); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + }); }); - // ---------------- LOGOUT ---------------- - it('should logout a logged-in user', async () => { - const agent = request.agent(app); + // ---------------- SESSION COOKIE FLAGS ---------------- + describe('Session cookie security flags', () => { + it('should set HttpOnly on the session cookie after login', async () => { + await User.create({ + username: 'cookieuser', + email: 'cookie@example.com', + password: VALID_PASSWORD, + }); + + const res = await request(app) + .post('/auth/login') + .set('Origin', ALLOWED_ORIGIN) + .send({ email: 'cookie@example.com', password: VALID_PASSWORD }); - await agent.post('/auth/signup').send({ - username: 'testuser', - email: 'test@example.com', - password: 'password123', + expect(res.status).toBe(200); + + const setCookie = res.headers['set-cookie']; + expect(setCookie).toBeTruthy(); + + const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie; + expect(cookieStr).toMatch(/HttpOnly/i); }); - await agent.post('/auth/login').send({ - email: 'test@example.com', - password: 'password123', + it('should set SameSite=Strict on the session cookie after login', async () => { + await User.create({ + username: 'samesiteuser', + email: 'samesite@example.com', + password: VALID_PASSWORD, + }); + + const res = await request(app) + .post('/auth/login') + .set('Origin', ALLOWED_ORIGIN) + .send({ email: 'samesite@example.com', password: VALID_PASSWORD }); + + expect(res.status).toBe(200); + + const setCookie = res.headers['set-cookie']; + expect(setCookie).toBeTruthy(); + + const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie; + expect(cookieStr).toMatch(/SameSite=Strict/i); }); + }); +}); + +// --------------------------------------------------------------------------- +// Environment validation — pure unit tests, no MongoDB required. +// --------------------------------------------------------------------------- +describe('Environment validation (validateEnv)', () => { + let savedNodeEnv; + let savedFrontendOrigin; + let hadFrontendOrigin; + + beforeEach(() => { + savedNodeEnv = process.env.NODE_ENV; + hadFrontendOrigin = Object.prototype.hasOwnProperty.call(process.env, 'FRONTEND_ORIGIN'); + savedFrontendOrigin = process.env.FRONTEND_ORIGIN; + }); + + afterEach(() => { + process.env.NODE_ENV = savedNodeEnv; + if (hadFrontendOrigin) { + process.env.FRONTEND_ORIGIN = savedFrontendOrigin; + } else { + delete process.env.FRONTEND_ORIGIN; + } + }); + + it('should throw when FRONTEND_ORIGIN is absent in production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.FRONTEND_ORIGIN; + + expect(() => validateEnv()).toThrow(); + }); + + it('should include FRONTEND_ORIGIN in the error message when throwing in production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.FRONTEND_ORIGIN; + + expect(() => validateEnv()).toThrowError(/FRONTEND_ORIGIN/); + }); + + it('should not throw when FRONTEND_ORIGIN is set in production', () => { + process.env.NODE_ENV = 'production'; + process.env.FRONTEND_ORIGIN = 'https://app.example.com'; + + expect(() => validateEnv()).not.toThrow(); + }); + + it('should not throw in development without FRONTEND_ORIGIN', () => { + process.env.NODE_ENV = 'development'; + delete process.env.FRONTEND_ORIGIN; + + expect(() => validateEnv()).not.toThrow(); + }); - const res = await agent.get('/auth/logout'); + it('should not throw in test environment without FRONTEND_ORIGIN', () => { + process.env.NODE_ENV = 'test'; + delete process.env.FRONTEND_ORIGIN; - expect(res.status).toBe(200); - expect(res.body.message).toBe('Logged out successfully'); + expect(() => validateEnv()).not.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/spec/user.model.spec.cjs b/spec/user.model.spec.cjs index e256e638..a6295e3a 100644 --- a/spec/user.model.spec.cjs +++ b/spec/user.model.spec.cjs @@ -1,5 +1,12 @@ -const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); + +// Resolve mongoose from the backend directory so this test and the User model +// share one mongoose instance, avoiding connection-state mismatches when +// backend/node_modules is present alongside the root node_modules. +const mongoose = require( + require.resolve('mongoose', { paths: [`${__dirname}/../backend`] }) +); + const User = require('../backend/models/User'); describe('User Model', () => { @@ -65,4 +72,4 @@ describe('User Model', () => { expect(isMatch).toBe(true); expect(isNotMatch).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 41573d83..f1f07b4f 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -34,11 +34,9 @@ const Login: React.FC = () => { setIsLoading(true); try { - const response = await axios.post( - `${backendUrl}/api/auth/login`, - formData, - { withCredentials: true } - ); + const response = await axios.post(`${backendUrl}/api/auth/login`, formData, { + withCredentials: true, + }); setMessage(response.data.message); if (response.data.message === "Login successful") { diff --git a/src/pages/Signup/Signup.tsx b/src/pages/Signup/Signup.tsx index 3cf95efd..53fd0dcc 100644 --- a/src/pages/Signup/Signup.tsx +++ b/src/pages/Signup/Signup.tsx @@ -100,12 +100,10 @@ const SignUp: React.FC = () => { } setIsLoading(true); try { - const response = await axios.post( - `${backendUrl}/api/auth/signup`, - formData, - { withCredentials: true } - ); - setMessage(response.data.message); + const response = await axios.post(`${backendUrl}/api/auth/signup`, formData, { + withCredentials: true, + }); + setMessage(response.data.message); // Show success message from backend // Navigate to login page after successful signup if (response.data.message === 'User created successfully') {