From 8c211bc07e01521c517e894994b5bcdf55cba43d Mon Sep 17 00:00:00 2001 From: Vasyl Yaremchuk Date: Tue, 5 Aug 2025 07:07:02 +0300 Subject: [PATCH 1/4] Fix error on login page on prod. domain. --- website/app.js | 86 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/website/app.js b/website/app.js index cb785703..73674eff 100644 --- a/website/app.js +++ b/website/app.js @@ -3,15 +3,21 @@ require('dotenv').config({ path: '../.env' }); const { getEnv } = require('./utils/env'); function createAposConfig() { + const isProduction = process.env.NODE_ENV === 'production'; + const baseUrl = getEnv('BASE_URL') || (isProduction ? 'https://speedandfunction.com' : 'http://localhost:3000'); + return { shortName: 'apostrophe-site', - baseUrl: getEnv('BASE_URL'), - + baseUrl: baseUrl, + // Session configuration modules: { // Core modules configuration '@apostrophecms/express': { options: { + // Trust proxy for Railway deployment + trustProxy: true, + session: { // If using Redis (recommended for production) secret: getEnv('SESSION_SECRET'), @@ -21,20 +27,70 @@ function createAposConfig() { url: getEnv('REDIS_URI'), }, }, + cookie: { + // Set domain for production to work with custom domain + domain: isProduction ? '.speedandfunction.com' : undefined, + secure: isProduction, + sameSite: 'lax', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }, }, + csrf: { cookie: { key: '_csrf', path: '/', httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: isProduction, sameSite: 'lax', maxAge: 3600, + // CRITICAL: Set domain for CSRF cookie to work with custom domain + domain: isProduction ? '.speedandfunction.com' : undefined, }, + // Additional CSRF options for better security + ignoreMethods: ['GET', 'HEAD', 'OPTIONS'], + value: (req) => { + return req.body && req.body._csrf || + req.query && req.query._csrf || + req.headers['x-csrf-token'] || + req.headers['x-xsrf-token'] || + req.headers['csrf-token']; + } }, + + // Add middleware to handle domain-specific headers + middleware: [ + { + before: '@apostrophecms/csrf', + middleware: (req, res, next) => { + // Ensure proper headers for custom domain + if (req.hostname === 'speedandfunction.com' || req.get('host') === 'speedandfunction.com') { + req.headers['x-forwarded-host'] = 'speedandfunction.com'; + req.headers['x-forwarded-proto'] = 'https'; + } + + // Set CORS headers for API requests + const allowedOrigins = [ + 'https://speedandfunction.com', + 'https://apostrophe-cms-production.up.railway.app' + ]; + + const origin = req.headers.origin; + if (allowedOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN'); + } + + next(); + } + } + ] }, }, - + // Make getEnv function available to templates '@apostrophecms/template': { options: { @@ -43,13 +99,13 @@ function createAposConfig() { }, }, }, - + // Add global data module 'global-data': {}, - + // Shared constants module '@apostrophecms/shared-constants': {}, - + // Configure page types '@apostrophecms/rich-text-widget': {}, '@apostrophecms/image-widget': { @@ -63,7 +119,7 @@ function createAposConfig() { className: 'bp-video-widget', }, }, - + // Custom Widgets 'home-hero-widget': {}, 'default-hero-widget': {}, @@ -79,11 +135,7 @@ function createAposConfig() { 'contact-widget': {}, 'page-intro-widget': {}, 'whitespace-widget': {}, - /* - * 'links-buttons-widget': {}, - * 'team-carousel-widget': {}, - */ - + // The main form module '@apostrophecms/form': {}, // The form widget module, allowing editors to add forms to content areas @@ -92,13 +144,14 @@ function createAposConfig() { '@apostrophecms/form-text-field-widget': {}, '@apostrophecms/form-textarea-field-widget': {}, '@apostrophecms/form-checkboxes-field-widget': {}, - + // Custom Pieces 'team-members': {}, 'testimonials': {}, - + // `asset` supports the project"s webpack build for client-side assets. 'asset': {}, + // The project"s first custom page type. 'default-page': {}, '@apostrophecms/import-export': {}, @@ -121,4 +174,5 @@ function createAposConfig() { if (require.main === module) { apostrophe(createAposConfig()); } -module.exports = { createAposConfig }; + +module.exports = { createAposConfig }; \ No newline at end of file From 96aa47d468c5a6bda4691a7ccda4078890d51a7f Mon Sep 17 00:00:00 2001 From: Vasyl Yaremchuk Date: Tue, 5 Aug 2025 08:23:00 +0300 Subject: [PATCH 2/4] Fix linter errors. --- website/app.js | 130 ++++++++++++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 50 deletions(-) diff --git a/website/app.js b/website/app.js index 73674eff..a6090d14 100644 --- a/website/app.js +++ b/website/app.js @@ -4,12 +4,19 @@ const { getEnv } = require('./utils/env'); function createAposConfig() { const isProduction = process.env.NODE_ENV === 'production'; - const baseUrl = getEnv('BASE_URL') || (isProduction ? 'https://speedandfunction.com' : 'http://localhost:3000'); - + let baseUrl = getEnv('BASE_URL'); + if (!baseUrl) { + if (isProduction) { + baseUrl = 'https://speedandfunction.com'; + } else { + baseUrl = 'http://localhost:3000'; + } + } + return { shortName: 'apostrophe-site', - baseUrl: baseUrl, - + baseUrl, + // Session configuration modules: { // Core modules configuration @@ -17,7 +24,7 @@ function createAposConfig() { options: { // Trust proxy for Railway deployment trustProxy: true, - + session: { // If using Redis (recommended for production) secret: getEnv('SESSION_SECRET'), @@ -27,70 +34,93 @@ function createAposConfig() { url: getEnv('REDIS_URI'), }, }, - cookie: { + cookie: (() => { + const cookieConfig = { + secure: isProduction, + sameSite: 'lax', + httpOnly: true, + // 24 hours + maxAge: 24 * 60 * 60 * 1000, + }; // Set domain for production to work with custom domain - domain: isProduction ? '.speedandfunction.com' : undefined, - secure: isProduction, - sameSite: 'lax', - httpOnly: true, - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }, + if (isProduction) { + cookieConfig.domain = '.speedandfunction.com'; + } + return cookieConfig; + })(), }, - + csrf: { - cookie: { - key: '_csrf', - path: '/', - httpOnly: true, - secure: isProduction, - sameSite: 'lax', - maxAge: 3600, + cookie: (() => { + const csrfCookieConfig = { + key: '_csrf', + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 3600, + }; // CRITICAL: Set domain for CSRF cookie to work with custom domain - domain: isProduction ? '.speedandfunction.com' : undefined, - }, + if (isProduction) { + csrfCookieConfig.domain = '.speedandfunction.com'; + } + return csrfCookieConfig; + })(), // Additional CSRF options for better security ignoreMethods: ['GET', 'HEAD', 'OPTIONS'], value: (req) => { - return req.body && req.body._csrf || - req.query && req.query._csrf || - req.headers['x-csrf-token'] || - req.headers['x-xsrf-token'] || - req.headers['csrf-token']; - } + const csrfKey = '_csrf'; + return ( + (req.body && req.body[csrfKey]) || + (req.query && req.query[csrfKey]) || + req.headers['x-csrf-token'] || + req.headers['x-xsrf-token'] || + req.headers['csrf-token'] + ); + }, }, - + // Add middleware to handle domain-specific headers middleware: [ { before: '@apostrophecms/csrf', middleware: (req, res, next) => { // Ensure proper headers for custom domain - if (req.hostname === 'speedandfunction.com' || req.get('host') === 'speedandfunction.com') { + if ( + req.hostname === 'speedandfunction.com' || + req.get('host') === 'speedandfunction.com' + ) { req.headers['x-forwarded-host'] = 'speedandfunction.com'; req.headers['x-forwarded-proto'] = 'https'; } - + // Set CORS headers for API requests const allowedOrigins = [ 'https://speedandfunction.com', - 'https://apostrophe-cms-production.up.railway.app' + 'https://apostrophe-cms-production.up.railway.app', ]; - - const origin = req.headers.origin; + + const { origin } = req.headers; if (allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN'); + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, OPTIONS', + ); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN', + ); } - + next(); - } - } - ] + }, + }, + ], }, }, - + // Make getEnv function available to templates '@apostrophecms/template': { options: { @@ -99,13 +129,13 @@ function createAposConfig() { }, }, }, - + // Add global data module 'global-data': {}, - + // Shared constants module '@apostrophecms/shared-constants': {}, - + // Configure page types '@apostrophecms/rich-text-widget': {}, '@apostrophecms/image-widget': { @@ -119,7 +149,7 @@ function createAposConfig() { className: 'bp-video-widget', }, }, - + // Custom Widgets 'home-hero-widget': {}, 'default-hero-widget': {}, @@ -135,7 +165,7 @@ function createAposConfig() { 'contact-widget': {}, 'page-intro-widget': {}, 'whitespace-widget': {}, - + // The main form module '@apostrophecms/form': {}, // The form widget module, allowing editors to add forms to content areas @@ -144,14 +174,14 @@ function createAposConfig() { '@apostrophecms/form-text-field-widget': {}, '@apostrophecms/form-textarea-field-widget': {}, '@apostrophecms/form-checkboxes-field-widget': {}, - + // Custom Pieces 'team-members': {}, 'testimonials': {}, - + // `asset` supports the project"s webpack build for client-side assets. 'asset': {}, - + // The project"s first custom page type. 'default-page': {}, '@apostrophecms/import-export': {}, @@ -175,4 +205,4 @@ if (require.main === module) { apostrophe(createAposConfig()); } -module.exports = { createAposConfig }; \ No newline at end of file +module.exports = { createAposConfig }; From 074329a1b6e355e52e286436345a74a7c39555a9 Mon Sep 17 00:00:00 2001 From: Vasyl Yaremchuk Date: Tue, 5 Aug 2025 17:32:03 +0300 Subject: [PATCH 3/4] Tests to cover changes in app.js --- website/app.test.js | 340 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) diff --git a/website/app.test.js b/website/app.test.js index 54b9d58d..3a77e5e0 100644 --- a/website/app.test.js +++ b/website/app.test.js @@ -161,6 +161,346 @@ describe('createAposConfig', () => { const config = createAposConfig(); expect(config.shortName).toBe('apostrophe-site'); }); + + // Tests for specific code blocks (L6-L14: Environment and baseUrl logic) + describe('Environment and baseUrl logic (L6-L14)', () => { + beforeEach(() => { + delete process.env.BASE_URL; + delete process.env.NODE_ENV; + }); + + test('uses production baseUrl when NODE_ENV is production and BASE_URL is not set', () => { + process.env.NODE_ENV = 'production'; + const config = createAposConfig(); + expect(config.baseUrl).toBe('https://speedandfunction.com'); + }); + + test('uses localhost baseUrl when NODE_ENV is not production and BASE_URL is not set', () => { + process.env.NODE_ENV = 'development'; + const config = createAposConfig(); + expect(config.baseUrl).toBe('http://localhost:3000'); + }); + + test('uses localhost baseUrl when NODE_ENV is undefined and BASE_URL is not set', () => { + delete process.env.NODE_ENV; + const config = createAposConfig(); + expect(config.baseUrl).toBe('http://localhost:3000'); + }); + + test('uses BASE_URL environment variable when set, regardless of NODE_ENV', () => { + process.env.NODE_ENV = 'production'; + process.env.BASE_URL = 'https://custom-domain.com'; + const config = createAposConfig(); + expect(config.baseUrl).toBe('https://custom-domain.com'); + }); + + test('uses BASE_URL environment variable in development', () => { + process.env.NODE_ENV = 'development'; + process.env.BASE_URL = 'http://dev.example.com'; + const config = createAposConfig(); + expect(config.baseUrl).toBe('http://dev.example.com'); + }); + }); + + // Tests for L25-L26: Trust proxy configuration + describe('Trust proxy configuration (L25-L26)', () => { + test('sets trustProxy to true', () => { + const config = createAposConfig(); + expect(config.modules['@apostrophecms/express'].options.trustProxy).toBe(true); + }); + }); + + // Tests for L37-L51: Cookie configuration + describe('Cookie configuration (L37-L51)', () => { + beforeEach(() => { + delete process.env.NODE_ENV; + }); + + test('configures cookie for development environment', () => { + process.env.NODE_ENV = 'development'; + const config = createAposConfig(); + const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; + + expect(cookieConfig.secure).toBe(false); + expect(cookieConfig.sameSite).toBe('lax'); + expect(cookieConfig.httpOnly).toBe(true); + expect(cookieConfig.maxAge).toBe(24 * 60 * 60 * 1000); // 24 hours + expect(cookieConfig.domain).toBeUndefined(); + }); + + test('configures cookie for production environment with domain', () => { + process.env.NODE_ENV = 'production'; + const config = createAposConfig(); + const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; + + expect(cookieConfig.secure).toBe(true); + expect(cookieConfig.sameSite).toBe('lax'); + expect(cookieConfig.httpOnly).toBe(true); + expect(cookieConfig.maxAge).toBe(24 * 60 * 60 * 1000); // 24 hours + expect(cookieConfig.domain).toBe('.speedandfunction.com'); + }); + + test('configures cookie for undefined NODE_ENV (defaults to development)', () => { + delete process.env.NODE_ENV; + const config = createAposConfig(); + const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; + + expect(cookieConfig.secure).toBe(false); + expect(cookieConfig.domain).toBeUndefined(); + }); + }); + + // Tests for L55-L66: CSRF cookie configuration + describe('CSRF cookie configuration (L55-L66)', () => { + beforeEach(() => { + delete process.env.NODE_ENV; + }); + + test('configures CSRF cookie for development environment', () => { + process.env.NODE_ENV = 'development'; + const config = createAposConfig(); + const csrfCookieConfig = config.modules['@apostrophecms/express'].options.csrf.cookie; + + expect(csrfCookieConfig.key).toBe('_csrf'); + expect(csrfCookieConfig.path).toBe('/'); + expect(csrfCookieConfig.httpOnly).toBe(true); + expect(csrfCookieConfig.secure).toBe(false); + expect(csrfCookieConfig.sameSite).toBe('lax'); + expect(csrfCookieConfig.maxAge).toBe(3600); + expect(csrfCookieConfig.domain).toBeUndefined(); + }); + + test('configures CSRF cookie for production environment with domain', () => { + process.env.NODE_ENV = 'production'; + const config = createAposConfig(); + const csrfCookieConfig = config.modules['@apostrophecms/express'].options.csrf.cookie; + + expect(csrfCookieConfig.key).toBe('_csrf'); + expect(csrfCookieConfig.path).toBe('/'); + expect(csrfCookieConfig.httpOnly).toBe(true); + expect(csrfCookieConfig.secure).toBe(true); + expect(csrfCookieConfig.sameSite).toBe('lax'); + expect(csrfCookieConfig.maxAge).toBe(3600); + expect(csrfCookieConfig.domain).toBe('.speedandfunction.com'); + }); + }); + + // Tests for CSRF value function + describe('CSRF value function', () => { + test('extracts CSRF token from request body', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: { _csrf: 'body-token' }, + query: {}, + headers: {} + }; + + expect(csrfValueFn(mockReq)).toBe('body-token'); + }); + + test('extracts CSRF token from query parameters', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: {}, + query: { _csrf: 'query-token' }, + headers: {} + }; + + expect(csrfValueFn(mockReq)).toBe('query-token'); + }); + + test('extracts CSRF token from x-csrf-token header', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: {}, + query: {}, + headers: { 'x-csrf-token': 'header-token' } + }; + + expect(csrfValueFn(mockReq)).toBe('header-token'); + }); + + test('extracts CSRF token from x-xsrf-token header', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: {}, + query: {}, + headers: { 'x-xsrf-token': 'xsrf-token' } + }; + + expect(csrfValueFn(mockReq)).toBe('xsrf-token'); + }); + + test('extracts CSRF token from csrf-token header', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: {}, + query: {}, + headers: { 'csrf-token': 'csrf-header-token' } + }; + + expect(csrfValueFn(mockReq)).toBe('csrf-header-token'); + }); + + test('prioritizes body over query and headers', () => { + const config = createAposConfig(); + const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; + + const mockReq = { + body: { _csrf: 'body-token' }, + query: { _csrf: 'query-token' }, + headers: { 'x-csrf-token': 'header-token' } + }; + + expect(csrfValueFn(mockReq)).toBe('body-token'); + }); + }); + + // Tests for L89-L96: Hostname and header logic + describe('Hostname and header logic middleware (L89-L96)', () => { + let middleware; + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + const config = createAposConfig(); + middleware = config.modules['@apostrophecms/express'].options.middleware[0].middleware; + + mockReq = { + hostname: '', + headers: {}, + get: jest.fn() + }; + mockRes = { + setHeader: jest.fn() + }; + mockNext = jest.fn(); + }); + + test('sets headers when hostname is speedandfunction.com', () => { + mockReq.hostname = 'speedandfunction.com'; + + middleware(mockReq, mockRes, mockNext); + + expect(mockReq.headers['x-forwarded-host']).toBe('speedandfunction.com'); + expect(mockReq.headers['x-forwarded-proto']).toBe('https'); + expect(mockNext).toHaveBeenCalled(); + }); + + test('sets headers when host header is speedandfunction.com', () => { + mockReq.hostname = 'other.com'; + mockReq.get.mockReturnValue('speedandfunction.com'); + + middleware(mockReq, mockRes, mockNext); + + expect(mockReq.headers['x-forwarded-host']).toBe('speedandfunction.com'); + expect(mockReq.headers['x-forwarded-proto']).toBe('https'); + expect(mockNext).toHaveBeenCalled(); + }); + + test('does not set headers for other hostnames', () => { + mockReq.hostname = 'localhost'; + mockReq.get.mockReturnValue('localhost:3000'); + + middleware(mockReq, mockRes, mockNext); + + expect(mockReq.headers['x-forwarded-host']).toBeUndefined(); + expect(mockReq.headers['x-forwarded-proto']).toBeUndefined(); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + // Tests for L97-L112: CORS headers + describe('CORS headers middleware (L97-L112)', () => { + let middleware; + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + const config = createAposConfig(); + middleware = config.modules['@apostrophecms/express'].options.middleware[0].middleware; + + mockReq = { + hostname: '', + headers: {}, + get: jest.fn() + }; + mockRes = { + setHeader: jest.fn() + }; + mockNext = jest.fn(); + }); + + test('sets CORS headers for allowed origin speedandfunction.com', () => { + mockReq.headers.origin = 'https://speedandfunction.com'; + + middleware(mockReq, mockRes, mockNext); + + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://speedandfunction.com'); + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true'); + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN' + ); + expect(mockNext).toHaveBeenCalled(); + }); + + test('sets CORS headers for allowed origin apostrophe-cms-production.up.railway.app', () => { + mockReq.headers.origin = 'https://apostrophe-cms-production.up.railway.app'; + + middleware(mockReq, mockRes, mockNext); + + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://apostrophe-cms-production.up.railway.app'); + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true'); + expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN' + ); + expect(mockNext).toHaveBeenCalled(); + }); + + test('does not set CORS headers for disallowed origins', () => { + mockReq.headers.origin = 'https://malicious-site.com'; + + middleware(mockReq, mockRes, mockNext); + + expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Origin', expect.anything()); + expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Credentials', expect.anything()); + expect(mockNext).toHaveBeenCalled(); + }); + + test('does not set CORS headers when no origin header is present', () => { + delete mockReq.headers.origin; + + middleware(mockReq, mockRes, mockNext); + + expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Origin', expect.anything()); + expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Credentials', expect.anything()); + expect(mockNext).toHaveBeenCalled(); + }); + + test('middleware is configured to run before @apostrophecms/csrf', () => { + const config = createAposConfig(); + const middlewareConfig = config.modules['@apostrophecms/express'].options.middleware[0]; + + expect(middlewareConfig.before).toBe('@apostrophecms/csrf'); + expect(typeof middlewareConfig.middleware).toBe('function'); + }); + }); }); describe('module.exports', () => { From f7be78051e39d7b0414a348450122e2720bb696f Mon Sep 17 00:00:00 2001 From: Vasyl Yaremchuk Date: Tue, 5 Aug 2025 17:48:47 +0300 Subject: [PATCH 4/4] Revert "Tests to cover changes in app.js" This reverts commit 074329a1b6e355e52e286436345a74a7c39555a9. --- website/app.test.js | 340 -------------------------------------------- 1 file changed, 340 deletions(-) diff --git a/website/app.test.js b/website/app.test.js index 3a77e5e0..54b9d58d 100644 --- a/website/app.test.js +++ b/website/app.test.js @@ -161,346 +161,6 @@ describe('createAposConfig', () => { const config = createAposConfig(); expect(config.shortName).toBe('apostrophe-site'); }); - - // Tests for specific code blocks (L6-L14: Environment and baseUrl logic) - describe('Environment and baseUrl logic (L6-L14)', () => { - beforeEach(() => { - delete process.env.BASE_URL; - delete process.env.NODE_ENV; - }); - - test('uses production baseUrl when NODE_ENV is production and BASE_URL is not set', () => { - process.env.NODE_ENV = 'production'; - const config = createAposConfig(); - expect(config.baseUrl).toBe('https://speedandfunction.com'); - }); - - test('uses localhost baseUrl when NODE_ENV is not production and BASE_URL is not set', () => { - process.env.NODE_ENV = 'development'; - const config = createAposConfig(); - expect(config.baseUrl).toBe('http://localhost:3000'); - }); - - test('uses localhost baseUrl when NODE_ENV is undefined and BASE_URL is not set', () => { - delete process.env.NODE_ENV; - const config = createAposConfig(); - expect(config.baseUrl).toBe('http://localhost:3000'); - }); - - test('uses BASE_URL environment variable when set, regardless of NODE_ENV', () => { - process.env.NODE_ENV = 'production'; - process.env.BASE_URL = 'https://custom-domain.com'; - const config = createAposConfig(); - expect(config.baseUrl).toBe('https://custom-domain.com'); - }); - - test('uses BASE_URL environment variable in development', () => { - process.env.NODE_ENV = 'development'; - process.env.BASE_URL = 'http://dev.example.com'; - const config = createAposConfig(); - expect(config.baseUrl).toBe('http://dev.example.com'); - }); - }); - - // Tests for L25-L26: Trust proxy configuration - describe('Trust proxy configuration (L25-L26)', () => { - test('sets trustProxy to true', () => { - const config = createAposConfig(); - expect(config.modules['@apostrophecms/express'].options.trustProxy).toBe(true); - }); - }); - - // Tests for L37-L51: Cookie configuration - describe('Cookie configuration (L37-L51)', () => { - beforeEach(() => { - delete process.env.NODE_ENV; - }); - - test('configures cookie for development environment', () => { - process.env.NODE_ENV = 'development'; - const config = createAposConfig(); - const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; - - expect(cookieConfig.secure).toBe(false); - expect(cookieConfig.sameSite).toBe('lax'); - expect(cookieConfig.httpOnly).toBe(true); - expect(cookieConfig.maxAge).toBe(24 * 60 * 60 * 1000); // 24 hours - expect(cookieConfig.domain).toBeUndefined(); - }); - - test('configures cookie for production environment with domain', () => { - process.env.NODE_ENV = 'production'; - const config = createAposConfig(); - const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; - - expect(cookieConfig.secure).toBe(true); - expect(cookieConfig.sameSite).toBe('lax'); - expect(cookieConfig.httpOnly).toBe(true); - expect(cookieConfig.maxAge).toBe(24 * 60 * 60 * 1000); // 24 hours - expect(cookieConfig.domain).toBe('.speedandfunction.com'); - }); - - test('configures cookie for undefined NODE_ENV (defaults to development)', () => { - delete process.env.NODE_ENV; - const config = createAposConfig(); - const cookieConfig = config.modules['@apostrophecms/express'].options.session.cookie; - - expect(cookieConfig.secure).toBe(false); - expect(cookieConfig.domain).toBeUndefined(); - }); - }); - - // Tests for L55-L66: CSRF cookie configuration - describe('CSRF cookie configuration (L55-L66)', () => { - beforeEach(() => { - delete process.env.NODE_ENV; - }); - - test('configures CSRF cookie for development environment', () => { - process.env.NODE_ENV = 'development'; - const config = createAposConfig(); - const csrfCookieConfig = config.modules['@apostrophecms/express'].options.csrf.cookie; - - expect(csrfCookieConfig.key).toBe('_csrf'); - expect(csrfCookieConfig.path).toBe('/'); - expect(csrfCookieConfig.httpOnly).toBe(true); - expect(csrfCookieConfig.secure).toBe(false); - expect(csrfCookieConfig.sameSite).toBe('lax'); - expect(csrfCookieConfig.maxAge).toBe(3600); - expect(csrfCookieConfig.domain).toBeUndefined(); - }); - - test('configures CSRF cookie for production environment with domain', () => { - process.env.NODE_ENV = 'production'; - const config = createAposConfig(); - const csrfCookieConfig = config.modules['@apostrophecms/express'].options.csrf.cookie; - - expect(csrfCookieConfig.key).toBe('_csrf'); - expect(csrfCookieConfig.path).toBe('/'); - expect(csrfCookieConfig.httpOnly).toBe(true); - expect(csrfCookieConfig.secure).toBe(true); - expect(csrfCookieConfig.sameSite).toBe('lax'); - expect(csrfCookieConfig.maxAge).toBe(3600); - expect(csrfCookieConfig.domain).toBe('.speedandfunction.com'); - }); - }); - - // Tests for CSRF value function - describe('CSRF value function', () => { - test('extracts CSRF token from request body', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: { _csrf: 'body-token' }, - query: {}, - headers: {} - }; - - expect(csrfValueFn(mockReq)).toBe('body-token'); - }); - - test('extracts CSRF token from query parameters', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: {}, - query: { _csrf: 'query-token' }, - headers: {} - }; - - expect(csrfValueFn(mockReq)).toBe('query-token'); - }); - - test('extracts CSRF token from x-csrf-token header', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: {}, - query: {}, - headers: { 'x-csrf-token': 'header-token' } - }; - - expect(csrfValueFn(mockReq)).toBe('header-token'); - }); - - test('extracts CSRF token from x-xsrf-token header', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: {}, - query: {}, - headers: { 'x-xsrf-token': 'xsrf-token' } - }; - - expect(csrfValueFn(mockReq)).toBe('xsrf-token'); - }); - - test('extracts CSRF token from csrf-token header', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: {}, - query: {}, - headers: { 'csrf-token': 'csrf-header-token' } - }; - - expect(csrfValueFn(mockReq)).toBe('csrf-header-token'); - }); - - test('prioritizes body over query and headers', () => { - const config = createAposConfig(); - const csrfValueFn = config.modules['@apostrophecms/express'].options.csrf.value; - - const mockReq = { - body: { _csrf: 'body-token' }, - query: { _csrf: 'query-token' }, - headers: { 'x-csrf-token': 'header-token' } - }; - - expect(csrfValueFn(mockReq)).toBe('body-token'); - }); - }); - - // Tests for L89-L96: Hostname and header logic - describe('Hostname and header logic middleware (L89-L96)', () => { - let middleware; - let mockReq; - let mockRes; - let mockNext; - - beforeEach(() => { - const config = createAposConfig(); - middleware = config.modules['@apostrophecms/express'].options.middleware[0].middleware; - - mockReq = { - hostname: '', - headers: {}, - get: jest.fn() - }; - mockRes = { - setHeader: jest.fn() - }; - mockNext = jest.fn(); - }); - - test('sets headers when hostname is speedandfunction.com', () => { - mockReq.hostname = 'speedandfunction.com'; - - middleware(mockReq, mockRes, mockNext); - - expect(mockReq.headers['x-forwarded-host']).toBe('speedandfunction.com'); - expect(mockReq.headers['x-forwarded-proto']).toBe('https'); - expect(mockNext).toHaveBeenCalled(); - }); - - test('sets headers when host header is speedandfunction.com', () => { - mockReq.hostname = 'other.com'; - mockReq.get.mockReturnValue('speedandfunction.com'); - - middleware(mockReq, mockRes, mockNext); - - expect(mockReq.headers['x-forwarded-host']).toBe('speedandfunction.com'); - expect(mockReq.headers['x-forwarded-proto']).toBe('https'); - expect(mockNext).toHaveBeenCalled(); - }); - - test('does not set headers for other hostnames', () => { - mockReq.hostname = 'localhost'; - mockReq.get.mockReturnValue('localhost:3000'); - - middleware(mockReq, mockRes, mockNext); - - expect(mockReq.headers['x-forwarded-host']).toBeUndefined(); - expect(mockReq.headers['x-forwarded-proto']).toBeUndefined(); - expect(mockNext).toHaveBeenCalled(); - }); - }); - - // Tests for L97-L112: CORS headers - describe('CORS headers middleware (L97-L112)', () => { - let middleware; - let mockReq; - let mockRes; - let mockNext; - - beforeEach(() => { - const config = createAposConfig(); - middleware = config.modules['@apostrophecms/express'].options.middleware[0].middleware; - - mockReq = { - hostname: '', - headers: {}, - get: jest.fn() - }; - mockRes = { - setHeader: jest.fn() - }; - mockNext = jest.fn(); - }); - - test('sets CORS headers for allowed origin speedandfunction.com', () => { - mockReq.headers.origin = 'https://speedandfunction.com'; - - middleware(mockReq, mockRes, mockNext); - - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://speedandfunction.com'); - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true'); - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - expect(mockRes.setHeader).toHaveBeenCalledWith( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN' - ); - expect(mockNext).toHaveBeenCalled(); - }); - - test('sets CORS headers for allowed origin apostrophe-cms-production.up.railway.app', () => { - mockReq.headers.origin = 'https://apostrophe-cms-production.up.railway.app'; - - middleware(mockReq, mockRes, mockNext); - - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://apostrophe-cms-production.up.railway.app'); - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true'); - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - expect(mockRes.setHeader).toHaveBeenCalledWith( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-CSRF-Token, X-XSRF-TOKEN' - ); - expect(mockNext).toHaveBeenCalled(); - }); - - test('does not set CORS headers for disallowed origins', () => { - mockReq.headers.origin = 'https://malicious-site.com'; - - middleware(mockReq, mockRes, mockNext); - - expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Origin', expect.anything()); - expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Credentials', expect.anything()); - expect(mockNext).toHaveBeenCalled(); - }); - - test('does not set CORS headers when no origin header is present', () => { - delete mockReq.headers.origin; - - middleware(mockReq, mockRes, mockNext); - - expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Origin', expect.anything()); - expect(mockRes.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Credentials', expect.anything()); - expect(mockNext).toHaveBeenCalled(); - }); - - test('middleware is configured to run before @apostrophecms/csrf', () => { - const config = createAposConfig(); - const middlewareConfig = config.modules['@apostrophecms/express'].options.middleware[0]; - - expect(middlewareConfig.before).toBe('@apostrophecms/csrf'); - expect(typeof middlewareConfig.middleware).toBe('function'); - }); - }); }); describe('module.exports', () => {