From 2dba05384f98e22d79e1c16f9595fca4971f05f9 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 31 May 2026 18:56:27 +0200 Subject: [PATCH] feat(auth): include user.complementary in /token response Generalizes the downstream patch applied by trawl_node: the `token` handler now projects `complementary` alongside the other user fields so per-user UI prefs / extras rehydrate across full-page refresh without local override. Adds regression test (auth.token.controller.unit.tests.js) to lock the field in the projection going forward. Closes #3738 --- modules/auth/controllers/auth.controller.js | 1 + .../tests/auth.token.controller.unit.tests.js | 173 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 modules/auth/tests/auth.token.controller.unit.tests.js diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 884dbb30f..81154a715 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -307,6 +307,7 @@ const token = async (req, res) => { emailVerified: req.user.emailVerified, currentOrganization: req.user.currentOrganization, lastLoginAt: req.user.lastLoginAt, + complementary: req.user.complementary, }; } diff --git a/modules/auth/tests/auth.token.controller.unit.tests.js b/modules/auth/tests/auth.token.controller.unit.tests.js new file mode 100644 index 000000000..ec9007e1d --- /dev/null +++ b/modules/auth/tests/auth.token.controller.unit.tests.js @@ -0,0 +1,173 @@ +/** + * Module dependencies. + * + * Unit tests for auth.controller `token` handler. + * Regression guard: /api/auth/token must include `complementary` in the user + * projection so per-user UI prefs / extras rehydrate correctly across refresh. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +describe('auth.controller.token — user projection:', () => { + beforeEach(() => { + jest.resetModules(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { getBrut: jest.fn(), update: jest.fn(), create: jest.fn(), remove: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { handleSignupOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn().mockResolvedValue(undefined) }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { + findByUserAndOrganization: jest.fn().mockResolvedValue(null), + listPendingByUser: jest.fn().mockResolvedValue([]), + }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn() }, + })); + + // Mock invite-only gate (auth.controller imports InvitationService) + jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({ + default: { + validateInviteToken: jest.fn().mockResolvedValue(null), + consumeInviteToken: jest.fn().mockResolvedValue(undefined), + }, + })); + }); + + test('should include `complementary` in the returned user object (regression guard: per-user UI prefs / extras must rehydrate across refresh)', async () => { + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { + user: { + id: 'u1', + provider: 'local', + roles: ['user'], + avatar: '', + email: 'a@b.com', + lastName: 'Doe', + firstName: 'Jane', + additionalProvidersData: {}, + emailVerified: true, + currentOrganization: null, + lastLoginAt: new Date(0), + complementary: { ui: { layout: 'grid' } }, + }, + }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.token(req, res); + + expect(res.json).toHaveBeenCalledTimes(1); + const payload = res.json.mock.calls[0][0]; + expect(payload.user).toBeDefined(); + expect(payload.user.complementary).toEqual({ ui: { layout: 'grid' } }); + // Sanity — existing projection keys still present + expect(payload.user.id).toBe('u1'); + expect(payload.user.email).toBe('a@b.com'); + }); + + test('should preserve a missing/undefined `complementary` as undefined (no crash)', async () => { + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { + user: { + id: 'u2', + provider: 'local', + roles: ['user'], + avatar: '', + email: 'c@d.com', + lastName: 'Smith', + firstName: 'John', + additionalProvidersData: {}, + emailVerified: true, + currentOrganization: null, + lastLoginAt: new Date(0), + // complementary intentionally omitted + }, + }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.token(req, res); + + const payload = res.json.mock.calls[0][0]; + expect(payload.user).toBeDefined(); + expect('complementary' in payload.user).toBe(true); + expect(payload.user.complementary).toBeUndefined(); + }); +});