Skip to content
Merged
23 changes: 20 additions & 3 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ const bootstrap = async () => {
};

/**
* log server configuration
* @desc Log server configuration and SaaS readiness summary to console.
* @returns {Promise<void>}
*/
const logConfiguration = () => {
const logConfiguration = async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Create server URL
const server = `${(config.secure && config.secure.credentials ? 'https://' : 'http://') + config.api.host}:${config.api.port}`;
// Logging initialization
Expand All @@ -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();
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Comment thread
PierreBrisorgueil marked this conversation as resolved.
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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/middlewares/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
16 changes: 16 additions & 0 deletions modules/home/controllers/home.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,27 @@ 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}
*/
Comment thread
PierreBrisorgueil marked this conversation as resolved.
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,
team,
page,
pageByName,
health,
readiness,
};
3 changes: 3 additions & 0 deletions modules/home/policies/home.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}

/**
Expand Down
2 changes: 2 additions & 0 deletions modules/home/routes/home.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions modules/home/services/home.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// 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',
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// 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',
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// 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,
};
167 changes: 165 additions & 2 deletions modules/home/tests/home.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ describe('Home integration tests:', () => {
let HomeService;
let adminToken;
let adminUser;
let userToken;
let regularUser;
let originalOrganizationsEnabled;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// init
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

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();
}
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

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'));
Expand All @@ -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 {
Expand Down
Loading