diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 81154a715..04d0d1d4e 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken'; import UserService from '../../users/services/users.service.js'; import InvitationService from '../services/auth.invitation.service.js'; +import { computeSignupCapacity } from '../services/auth.signupCapacity.js'; import config from '../../../config/index.js'; import model from '../../../lib/middlewares/model.js'; import mails from '../../../lib/helpers/mailer/index.js'; @@ -601,43 +602,50 @@ const signout = (req, res) => { * @desc Endpoint to expose public auth configuration (sign flags and organizations settings) * @param {Object} req - Express request object * @param {Object} res - Express response object - * @returns {void} Sends the public auth configuration in the HTTP response + * @returns {Promise} Sends the public auth configuration in the HTTP response */ -const getConfig = (req, res) => { - const data = { - sign: { - in: !!config.sign.in, - up: !!config.sign.up, - }, - oAuth: { - google: !!config.oAuth?.google?.clientID, - apple: !!config.oAuth?.apple?.clientID, - }, - organizations: { - enabled: !!config.organizations?.enabled, - domainMatching: !!config.organizations?.domainMatching, - autoCreate: !!config.organizations?.autoCreate, - }, - mail: { - configured: isMailerConfigured(), - }, - }; - - // Authenticated users get extended org config and billing config - if (req.user) { - data.organizations = { - ...data.organizations, - roles: config.organizations?.roles || [], - roleDescriptions: config.organizations?.roleDescriptions || {}, - }; - data.billing = { - enabled: !!config.billing?.enabled, - meterMode: !!config.billing?.meterMode, - equivalences: config.billing?.equivalences ?? null, +const getConfig = async (req, res) => { + try { + const { cap, remaining } = await computeSignupCapacity(config.sign?.cap, UserService.count); + const data = { + sign: { + in: !!config.sign.in, + up: !!config.sign.up, + cap, // null = uncapped; numeric = hard ceiling on total accounts (invited included) + remaining, // null = uncapped; else max(0, cap - totalAccounts) + }, + oAuth: { + google: !!config.oAuth?.google?.clientID, + apple: !!config.oAuth?.apple?.clientID, + }, + organizations: { + enabled: !!config.organizations?.enabled, + domainMatching: !!config.organizations?.domainMatching, + autoCreate: !!config.organizations?.autoCreate, + }, + mail: { + configured: isMailerConfigured(), + }, }; - } - responses.success(res, 'Auth config')(data); + // Authenticated users get extended org config and billing config + if (req.user) { + data.organizations = { + ...data.organizations, + roles: config.organizations?.roles || [], + roleDescriptions: config.organizations?.roleDescriptions || {}, + }; + data.billing = { + enabled: !!config.billing?.enabled, + meterMode: !!config.billing?.meterMode, + equivalences: config.billing?.equivalences ?? null, + }; + } + + responses.success(res, 'Auth config')(data); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } }; /** diff --git a/modules/auth/services/auth.signupCapacity.js b/modules/auth/services/auth.signupCapacity.js new file mode 100644 index 000000000..ea354e952 --- /dev/null +++ b/modules/auth/services/auth.signupCapacity.js @@ -0,0 +1,15 @@ +/** + * @desc Compute the public beta-capacity view for the auth config endpoint: + * the configured cap and how many seats remain. Skips the (collection-wide) + * count entirely when uncapped or when cap ≤ 0 (remaining is deterministically 0). + * @param {number|string|null|undefined} rawCap - config.sign.cap (null/undefined/non-numeric/blank = uncapped; 0 = fully closed, 0 seats) + * @param {() => Promise} countFn - async total-account counter (UserService.count) + * @returns {Promise<{cap: number|null, remaining: number|null}>} + */ +export const computeSignupCapacity = async (rawCap, countFn) => { + const cap = rawCap != null && String(rawCap).trim() !== '' ? Number(rawCap) : null; + if (cap == null || !Number.isFinite(cap)) return { cap: null, remaining: null }; + if (cap <= 0) return { cap, remaining: 0 }; + const remaining = Math.max(0, cap - (await countFn())); + return { cap, remaining }; +}; diff --git a/modules/auth/tests/auth.config.controller.unit.tests.js b/modules/auth/tests/auth.config.controller.unit.tests.js index 71016936a..8c73f9b4d 100644 --- a/modules/auth/tests/auth.config.controller.unit.tests.js +++ b/modules/auth/tests/auth.config.controller.unit.tests.js @@ -106,11 +106,14 @@ describe('auth.controller getConfig:', () => { const req = {}; // no req.user const res = {}; - AuthController.getConfig(req, res); + await AuthController.getConfig(req, res); expect(responses.success).toHaveBeenCalledWith(res, 'Auth config'); const [data] = mockResponses.successCb.mock.calls[0]; expect(data.billing).toBeUndefined(); + // sign.cap / sign.remaining must be null when config.sign has no cap (uncapped path) + expect(data.sign.cap).toBeNull(); + expect(data.sign.remaining).toBeNull(); }); test('data.billing defaults to false/false/null when config.billing is undefined (authenticated)', async () => { @@ -120,7 +123,7 @@ describe('auth.controller getConfig:', () => { const req = { user: { id: '1' } }; const res = {}; - AuthController.getConfig(req, res); + await AuthController.getConfig(req, res); const [data] = mockResponses.successCb.mock.calls[0]; expect(data.billing).toBeDefined(); @@ -137,7 +140,7 @@ describe('auth.controller getConfig:', () => { const req = { user: { id: '1' } }; const res = {}; - AuthController.getConfig(req, res); + await AuthController.getConfig(req, res); const [data] = mockResponses.successCb.mock.calls[0]; expect(data.billing).toBeDefined(); @@ -160,7 +163,7 @@ describe('auth.controller getConfig:', () => { const req = { user: { id: '1' } }; const res = {}; - AuthController.getConfig(req, res); + await AuthController.getConfig(req, res); const [data] = mockResponses.successCb.mock.calls[0]; expect(data.billing.equivalences).toEqual(equivalences); diff --git a/modules/auth/tests/auth.signupCapacity.unit.tests.js b/modules/auth/tests/auth.signupCapacity.unit.tests.js new file mode 100644 index 000000000..7bd6a3cba --- /dev/null +++ b/modules/auth/tests/auth.signupCapacity.unit.tests.js @@ -0,0 +1,55 @@ +import { jest } from '@jest/globals'; +import { computeSignupCapacity } from '../services/auth.signupCapacity.js'; + +describe('computeSignupCapacity', () => { + test('uncapped (null) → {cap:null, remaining:null} and count() not called', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity(null, countFn)).resolves.toEqual({ cap: null, remaining: null }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('uncapped (undefined) → {cap:null, remaining:null}', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity(undefined, countFn)).resolves.toEqual({ cap: null, remaining: null }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('capped → remaining = cap - count', async () => { + const countFn = jest.fn().mockResolvedValue(10); + await expect(computeSignupCapacity(50, countFn)).resolves.toEqual({ cap: 50, remaining: 40 }); + }); + + test('at/over cap → remaining floored at 0', async () => { + const countFn = jest.fn().mockResolvedValue(60); + await expect(computeSignupCapacity(50, countFn)).resolves.toEqual({ cap: 50, remaining: 0 }); + }); + + test('non-numeric cap → treated as uncapped', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity('abc', countFn)).resolves.toEqual({ cap: null, remaining: null }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('empty string cap → treated as uncapped (not coerced to 0)', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity('', countFn)).resolves.toEqual({ cap: null, remaining: null }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('whitespace-only cap → treated as uncapped (not coerced to 0)', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity(' ', countFn)).resolves.toEqual({ cap: null, remaining: null }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('cap = 0 → fully closed: {cap:0, remaining:0} and count is NOT called (short-circuit)', async () => { + const countFn = jest.fn(); + await expect(computeSignupCapacity(0, countFn)).resolves.toEqual({ cap: 0, remaining: 0 }); + expect(countFn).not.toHaveBeenCalled(); + }); + + test('numeric string cap ("42") → coerced: {cap:42, remaining:40}', async () => { + const countFn = jest.fn().mockResolvedValue(2); + await expect(computeSignupCapacity('42', countFn)).resolves.toEqual({ cap: 42, remaining: 40 }); + }); +});