diff --git a/.env.example b/.env.example index 096903299..bd39f653b 100644 --- a/.env.example +++ b/.env.example @@ -563,9 +563,9 @@ HELP_AND_FAQ_URL=https://librechat.ai # users always get the latest version. Customize # # only if you understand caching implications. # -# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate -# INDEX_HTML_PRAGMA=no-cache -# INDEX_HTML_EXPIRES=0 +# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate +# INDEX_PRAGMA=no-cache +# INDEX_EXPIRES=0 # no-cache: Forces validation with server before using cached version # no-store: Prevents storing the response entirely diff --git a/api/server/index.js b/api/server/index.js index 9812fa530..f7548f840 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -24,10 +24,13 @@ const routes = require('./routes'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; -const port = Number(PORT) || 3080; +// Allow PORT=0 to be used for automatic free port assignment +const port = isNaN(Number(PORT)) ? 3080 : Number(PORT); const host = HOST || 'localhost'; const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */ +const app = express(); + const startServer = async () => { if (typeof Bun !== 'undefined') { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; @@ -36,7 +39,6 @@ const startServer = async () => { logger.info('Connected to MongoDB'); await indexSync(); - const app = express(); app.disable('x-powered-by'); app.set('trust proxy', trusted_proxy); @@ -179,3 +181,6 @@ process.on('uncaughtException', (err) => { process.exit(1); }); + +// export app for easier testing purposes +module.exports = app; diff --git a/api/server/index.spec.js b/api/server/index.spec.js new file mode 100644 index 000000000..493229c2f --- /dev/null +++ b/api/server/index.spec.js @@ -0,0 +1,78 @@ +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +describe('Server Configuration', () => { + // Increase the default timeout to allow for Mongo cleanup + jest.setTimeout(30_000); + + let mongoServer; + let app; + + /** Mocked fs.readFileSync for index.html */ + const originalReadFileSync = fs.readFileSync; + beforeAll(() => { + fs.readFileSync = function (filepath, options) { + if (filepath.includes('index.html')) { + return 'LibreChat
'; + } + return originalReadFileSync(filepath, options); + }; + }); + + afterAll(() => { + // Restore original fs.readFileSync + fs.readFileSync = originalReadFileSync; + }); + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URI = mongoServer.getUri(); + process.env.PORT = '0'; // Use a random available port + app = require('~/server'); + + // Wait for the app to be healthy + await healthCheckPoll(app); + }); + + afterAll(async () => { + await mongoServer.stop(); + await mongoose.disconnect(); + }); + + it('should return OK for /health', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.text).toBe('OK'); + }); + + it('should not cache index page', async () => { + const response = await request(app).get('/'); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate'); + expect(response.headers['pragma']).toBe('no-cache'); + expect(response.headers['expires']).toBe('0'); + }); +}); + +// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely +async function healthCheckPoll(app, retries = 0) { + const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms + try { + const response = await request(app).get('/health'); + if (response.status === 200) { + return; // App is healthy + } + } catch (error) { + // Ignore connection errors during polling + } + + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 30)); + await healthCheckPoll(app, retries + 1); + } else { + throw new Error('App did not become healthy within 10 seconds.'); + } +} diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index 23713ddf6..5925a56be 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -14,6 +14,7 @@ const staticCache = (staticPath) => res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`); } }, + index: false, }); module.exports = staticCache; diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index 5ec279b98..beb9b8c2f 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -23,7 +23,7 @@ const { // Check required environment variables if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) { - return null; + module.exports = null; } const searchAttributes = [ diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js index 549c57d5a..f9f6d78c8 100644 --- a/api/test/__mocks__/logger.js +++ b/api/test/__mocks__/logger.js @@ -8,6 +8,7 @@ jest.mock('winston', () => { mockFormatFunction.printf = jest.fn(); mockFormatFunction.errors = jest.fn(); mockFormatFunction.splat = jest.fn(); + mockFormatFunction.json = jest.fn(); return { format: mockFormatFunction, createLogger: jest.fn().mockReturnValue({ @@ -19,6 +20,7 @@ jest.mock('winston', () => { transports: { Console: jest.fn(), DailyRotateFile: jest.fn(), + File: jest.fn(), }, addColors: jest.fn(), }; diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index f84b90743..ed92afd21 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -6,3 +6,7 @@ process.env.BAN_VIOLATIONS = 'true'; process.env.BAN_DURATION = '7200000'; process.env.BAN_INTERVAL = '20'; process.env.CI = 'true'; +process.env.JWT_SECRET = 'test'; +process.env.JWT_REFRESH_SECRET = 'test'; +process.env.CREDS_KEY = 'test'; +process.env.CREDS_IV = 'test';