diff --git a/lib/app.js b/lib/app.js index 5a94a950e..041298ebb 100644 --- a/lib/app.js +++ b/lib/app.js @@ -87,9 +87,10 @@ const bootstrap = async () => { }; /** - * log server configuration + * @desc Log server configuration and SaaS readiness summary to console. + * @returns {Promise} */ -const logConfiguration = () => { +const logConfiguration = async () => { // Create server URL const server = `${(config.secure && config.secure.credentials ? 'https://' : 'http://') + config.api.host}:${config.api.port}`; // Logging initialization @@ -99,6 +100,22 @@ const logConfiguration = () => { console.log(chalk.green(`Server: ${server}`)); console.log(chalk.green(`Database: ${config.db.uri}`)); if (config.cors.origin.length > 0) console.log(chalk.green(`Cors: ${config.cors.origin}`)); + + // SaaS readiness summary (skip in test to keep output clean) + if (process.env.NODE_ENV !== 'test') { + try { + const { default: HomeService } = await import('../modules/home/services/home.service.js'); + const checks = HomeService.getReadinessStatus(); + console.log(); + console.log(chalk.green('SaaS Readiness:')); + checks.forEach((c) => { + const icon = c.status === 'ok' ? chalk.green('OK') : chalk.yellow('WARN'); + console.log(` ${icon} ${c.category.padEnd(12)} ${c.message}`); + }); + } catch (err) { + console.log(chalk.yellow(` SaaS readiness check failed: ${err.message}`)); + } + } }; // Boot up the server @@ -118,7 +135,7 @@ const start = async () => { if (config.secure && config.secure.credentials) http = await nodeHttps.createServer(config.secure.credentials, app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host); else http = await nodeHttp.createServer(app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host); - logConfiguration(); + await logConfiguration(); return { db, orm, diff --git a/lib/middlewares/policy.js b/lib/middlewares/policy.js index a585fc96a..f20ae8e66 100644 --- a/lib/middlewares/policy.js +++ b/lib/middlewares/policy.js @@ -201,6 +201,7 @@ const deriveSubjectType = (routePath) => { if (routePath.startsWith('/api/tasks')) return 'Task'; if (routePath.startsWith('/api/uploads')) return 'Upload'; if (routePath.startsWith('/api/home')) return 'Home'; + if (routePath === '/api/admin/readiness') return 'Readiness'; if (routePath.startsWith('/api/admin/organizations')) return 'Organization'; if (routePath.includes('/requests')) return 'Membership'; if (routePath.includes('/members')) return 'Membership'; diff --git a/modules/home/controllers/home.controller.js b/modules/home/controllers/home.controller.js index dae22cf64..2c9d9c983 100644 --- a/modules/home/controllers/home.controller.js +++ b/modules/home/controllers/home.controller.js @@ -96,6 +96,21 @@ const health = (req, res) => { responses.success(res, 'health check')(payload); }; +/** + * @desc Endpoint to return SaaS readiness checks (admin only). + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {void} + */ +const readiness = (req, res) => { + try { + const data = HomeService.getReadinessStatus(); + responses.success(res, 'readiness check')(data); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + export default { releases, changelogs, @@ -103,4 +118,5 @@ export default { page, pageByName, health, + readiness, }; diff --git a/modules/home/policies/home.policy.js b/modules/home/policies/home.policy.js index 0d4d6684a..5f1ace8ab 100644 --- a/modules/home/policies/home.policy.js +++ b/modules/home/policies/home.policy.js @@ -12,6 +12,9 @@ */ export function homeAbilities(user, membership, { can }) { can('read', 'Home'); + if (Array.isArray(user?.roles) && user.roles.includes('admin')) { + can('manage', 'Readiness'); + } } /** diff --git a/modules/home/routes/home.route.js b/modules/home/routes/home.route.js index 8bf8dc0ea..cae39b492 100644 --- a/modules/home/routes/home.route.js +++ b/modules/home/routes/home.route.js @@ -35,6 +35,8 @@ export default (app) => { app.route('/api/home/team').all(policy.isAllowed).get(home.team); // markdown files app.route('/api/home/pages/:name').all(policy.isAllowed).get(home.page); + // readiness check — admin only (JWT + CASL) + app.route('/api/admin/readiness').all(passport.authenticate('jwt', { session: false }), policy.isAllowed).get(home.readiness); // Finish by binding the task middleware app.param('name', home.pageByName); diff --git a/modules/home/services/home.service.js b/modules/home/services/home.service.js index 44228d98a..61f638079 100644 --- a/modules/home/services/home.service.js +++ b/modules/home/services/home.service.js @@ -10,8 +10,16 @@ import mongoose from 'mongoose'; import AuthService from '../../auth/services/auth.service.js'; import config from '../../../config/index.js'; +import mailer from '../../../lib/helpers/mailer/index.js'; import HomeRepository from '../repositories/home.repository.js'; +/** + * @desc Check whether a config value is meaningfully set (non-empty, not a DEVKIT placeholder). + * @param {*} value - Config value to check + * @returns {boolean} true when value is a non-empty string and not a DEVKIT_NODE_ placeholder + */ +const isSet = (value) => !!(value && typeof value === 'string' && value.trim() !== '' && !value.startsWith('DEVKIT_NODE_')); + /** * @desc Function to get all admin users in db * @return {Promise} All users @@ -102,10 +110,81 @@ const getHealthStatus = () => { }; }; +/** + * @desc Run SaaS readiness checks against current configuration. + * Each check returns { category, status, message }. + * @returns {Array<{category: string, status: string, message: string}>} + */ +const getReadinessStatus = () => { + const checks = []; + + // config — domain + const domainSet = isSet(config.domain); + checks.push({ + category: 'config', + status: domainSet ? 'ok' : 'warning', + message: domainSet ? 'Domain configured' : 'Domain not configured', + }); + + // security — JWT secret + const jwtSecret = config.jwt?.secret; + const jwtInsecure = !jwtSecret || jwtSecret.trim() === '' || jwtSecret === 'WaosSecretKeyExampleToChnageAbsolutely'; + checks.push({ + category: 'security', + status: jwtInsecure ? 'warning' : 'ok', + message: jwtInsecure ? 'JWT secret is missing or default — change it before production' : 'JWT secret is custom', + }); + + // auth — OAuth providers + const oAuthProviders = []; + if (isSet(config.oAuth?.google?.clientID)) oAuthProviders.push('Google'); + if (isSet(config.oAuth?.apple?.clientID)) oAuthProviders.push('Apple'); + checks.push({ + category: 'auth', + status: oAuthProviders.length > 0 ? 'ok' : 'warning', + message: oAuthProviders.length > 0 ? `OAuth configured (${oAuthProviders.join(', ')})` : 'No OAuth provider configured', + }); + + // mail — mailer + const mailConfigured = mailer.isConfigured(); + checks.push({ + category: 'mail', + status: mailConfigured ? 'ok' : 'warning', + message: mailConfigured ? 'Mail provider configured' : 'No mail provider configured', + }); + + // billing — Stripe + const stripeConfigured = isSet(config.stripe?.secretKey); + checks.push({ + category: 'billing', + status: stripeConfigured ? 'ok' : 'warning', + message: stripeConfigured ? 'Stripe configured' : 'Stripe not configured', + }); + + // analytics — PostHog + const posthogConfigured = isSet(config.posthog?.apiKey); + checks.push({ + category: 'analytics', + status: posthogConfigured ? 'ok' : 'warning', + message: posthogConfigured ? 'PostHog configured' : 'PostHog not configured', + }); + + // monitoring — Sentry + const sentryConfigured = isSet(config.sentry?.dsn); + checks.push({ + category: 'monitoring', + status: sentryConfigured ? 'ok' : 'warning', + message: sentryConfigured ? 'Sentry configured' : 'Sentry not configured', + }); + + return checks; +}; + export default { page, releases, changelogs, team, getHealthStatus, + getReadinessStatus, }; diff --git a/modules/home/tests/home.integration.tests.js b/modules/home/tests/home.integration.tests.js index a99a1fb32..8112dfa08 100644 --- a/modules/home/tests/home.integration.tests.js +++ b/modules/home/tests/home.integration.tests.js @@ -19,6 +19,8 @@ describe('Home integration tests:', () => { let HomeService; let adminToken; let adminUser; + let userToken; + let regularUser; let originalOrganizationsEnabled; // init @@ -52,6 +54,18 @@ describe('Home integration tests:', () => { roles: ['admin'], }); adminToken = jwt.sign({ userId: adminUser.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn }); + + // Create regular user and sign JWT for readiness auth tests + await User.deleteOne({ email: 'regular-readiness@test.com' }); + regularUser = await User.create({ + firstName: 'Regular', + lastName: 'User', + email: 'regular-readiness@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + roles: ['user'], + }); + userToken = jwt.sign({ userId: regularUser.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn }); } catch (err) { console.log(err); expect(err).toBeFalsy(); @@ -188,6 +202,154 @@ describe('Home integration tests:', () => { }); }); + describe('Readiness', () => { + test('should return 401 for unauthenticated user', async () => { + const result = await agent.get('/api/admin/readiness').expect(401); + expect(result.body).toBeDefined(); + expect(result.status).toBe(401); + }); + + test('should return 403 for regular user', async () => { + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${userToken}`).expect(403); + expect(result.body.type).toBe('error'); + }); + + test('should return 200 with readiness checks for admin', async () => { + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('readiness check'); + expect(result.body.data).toBeInstanceOf(Array); + expect(result.body.data.length).toBeGreaterThan(0); + }); + + test('should return correct shape for each readiness check', async () => { + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const expectedCategories = ['config', 'security', 'auth', 'mail', 'billing', 'analytics', 'monitoring']; + const categories = result.body.data.map((c) => c.category); + expect(categories).toEqual(expectedCategories); + result.body.data.forEach((item) => { + expect(item).toHaveProperty('category'); + expect(item).toHaveProperty('status'); + expect(item).toHaveProperty('message'); + expect(['ok', 'warning']).toContain(item.status); + expect(typeof item.message).toBe('string'); + }); + }); + + test('should report ok status when config values are properly set', async () => { + const mailer = (await import('../../../lib/helpers/mailer/index.js')).default; + const origDomain = config.domain; + const origJwt = config.jwt.secret; + const origOAuth = config.oAuth; + const origStripe = config.stripe; + const origPosthog = config.posthog; + const origSentry = config.sentry; + const mailerSpy = jest.spyOn(mailer, 'isConfigured').mockReturnValue(true); + try { + config.domain = 'example.com'; + config.jwt.secret = 'a-real-custom-secret-key'; + config.oAuth = { google: { clientID: 'google-id' }, apple: { clientID: 'apple-id' } }; + config.stripe = { secretKey: 'sk_test_123' }; + config.posthog = { apiKey: 'phk_123' }; + config.sentry = { dsn: 'https://sentry.io/123' }; + + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + result.body.data.forEach((item) => { + expect(item.status).toBe('ok'); + }); + // Verify OAuth message includes both providers + const authCheck = result.body.data.find((c) => c.category === 'auth'); + expect(authCheck.message).toContain('Google'); + expect(authCheck.message).toContain('Apple'); + } finally { + config.domain = origDomain; + config.jwt.secret = origJwt; + config.oAuth = origOAuth; + config.stripe = origStripe; + config.posthog = origPosthog; + config.sentry = origSentry; + mailerSpy.mockRestore(); + } + }); + + test('should report warning when JWT secret is the default value', async () => { + const origJwt = config.jwt.secret; + try { + config.jwt.secret = 'WaosSecretKeyExampleToChnageAbsolutely'; + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const secCheck = result.body.data.find((c) => c.category === 'security'); + expect(secCheck.status).toBe('warning'); + expect(secCheck.message).toContain('default'); + } finally { + config.jwt.secret = origJwt; + } + }); + + test('should report warning when domain is a DEVKIT placeholder', async () => { + const origDomain = config.domain; + try { + config.domain = 'DEVKIT_NODE_DOMAIN'; + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const cfgCheck = result.body.data.find((c) => c.category === 'config'); + expect(cfgCheck.status).toBe('warning'); + } finally { + config.domain = origDomain; + } + }); + + test('should handle only Google OAuth configured', async () => { + const origOAuth = config.oAuth; + try { + config.oAuth = { google: { clientID: 'google-id' }, apple: {} }; + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const authCheck = result.body.data.find((c) => c.category === 'auth'); + expect(authCheck.status).toBe('ok'); + expect(authCheck.message).toContain('Google'); + expect(authCheck.message).not.toContain('Apple'); + } finally { + config.oAuth = origOAuth; + } + }); + + test('should report warning when config values are empty strings or whitespace', async () => { + const origDomain = config.domain; + const origStripe = config.stripe; + try { + config.domain = ' '; + config.stripe = { secretKey: '' }; + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const cfgCheck = result.body.data.find((c) => c.category === 'config'); + const billingCheck = result.body.data.find((c) => c.category === 'billing'); + expect(cfgCheck.status).toBe('warning'); + expect(billingCheck.status).toBe('warning'); + } finally { + config.domain = origDomain; + config.stripe = origStripe; + } + }); + + test('should report warning when JWT secret is empty', async () => { + const origJwt = config.jwt.secret; + try { + config.jwt.secret = ''; + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200); + const secCheck = result.body.data.find((c) => c.category === 'security'); + expect(secCheck.status).toBe('warning'); + } finally { + config.jwt.secret = origJwt; + } + }); + + test('should return 422 when readiness service throws', async () => { + jest.spyOn(HomeService, 'getReadinessStatus').mockImplementationOnce(() => { + throw new Error('config error'); + }); + const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.description).toBe('config error.'); + }); + }); + describe('Errors', () => { test('should return 422 when team service fails', async () => { jest.spyOn(HomeService, 'team').mockRejectedValueOnce(new Error('DB error')); @@ -211,9 +373,10 @@ describe('Home integration tests:', () => { jest.restoreAllMocks(); config.organizations.enabled = originalOrganizationsEnabled; try { - if (adminUser) { + if (adminUser || regularUser) { const User = mongoose.model('User'); - await User.deleteOne({ _id: adminUser._id }); + if (adminUser) await User.deleteOne({ _id: adminUser._id }); + if (regularUser) await User.deleteOne({ _id: regularUser._id }); } } catch (_) { /* cleanup – ignore errors */ } try {