diff --git a/jest.config.js b/jest.config.js index 638f010a6..5dd03f271 100644 --- a/jest.config.js +++ b/jest.config.js @@ -57,6 +57,8 @@ export default { '!/modules/**/migrations/**', // Exclude static upload config — just object literals, like config/defaults '!/modules/uploads/config/config.uploads.js', + // Exclude test fixtures — helper stubs used by tests, not production code + '!/**/tests/fixtures/**', ], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', @@ -180,7 +182,7 @@ export default { testMatch: ['/modules/*/tests/**/*.js', '/lib/**/tests/**/*.js'], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - testPathIgnorePatterns: ['/node_modules/'], + testPathIgnorePatterns: ['/node_modules/', '/tests/fixtures/'], // The regexp pattern Jest uses to detect test files // testRegex: "", diff --git a/lib/helpers/mailer/tests/mailer.unit.tests.js b/lib/helpers/mailer/tests/mailer.unit.tests.js index 86a8d96f1..faf000344 100644 --- a/lib/helpers/mailer/tests/mailer.unit.tests.js +++ b/lib/helpers/mailer/tests/mailer.unit.tests.js @@ -131,6 +131,18 @@ describe('mailer index with resend provider unit tests:', () => { ).rejects.toThrow('exceeds 25 MB limit'); }); + test('should throw when attachment filename is an empty string', async () => { + await expect( + mailer.sendMail({ + to: 'user@example.com', + subject: 'Test', + template: 'welcome', + params: { name: 'Bob' }, + attachments: [{ filename: '', content: 'data' }], + }), + ).rejects.toThrow('Attachment filename must be a non-empty string'); + }); + test('should throw when attachment filename is not a string', async () => { await expect( mailer.sendMail({ diff --git a/lib/helpers/mailer/tests/provider.resend.unit.tests.js b/lib/helpers/mailer/tests/provider.resend.unit.tests.js index e52b619d1..762dfd545 100644 --- a/lib/helpers/mailer/tests/provider.resend.unit.tests.js +++ b/lib/helpers/mailer/tests/provider.resend.unit.tests.js @@ -84,6 +84,26 @@ describe('ResendProvider unit tests:', () => { expect(result).toEqual(mockData); }); + test('should encode Buffer attachment content to base64', async () => { + const buffer = Buffer.from('binary data'); + const mockData = { id: 'email_buf' }; + sendMock.mockResolvedValue({ data: mockData, error: null }); + + await provider.send({ + from: 'from@test.com', + to: 'to@test.com', + subject: 'Test', + html: '

test

', + attachments: [{ filename: 'data.bin', content: buffer }], + }); + + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: [{ filename: 'data.bin', content: Buffer.from(buffer).toString('base64') }], + }), + ); + }); + test('should throw on Resend API error', async () => { sendMock.mockResolvedValue({ data: null, error: { message: 'Invalid API key' } }); diff --git a/lib/middlewares/policy.js b/lib/middlewares/policy.js index d251f56f6..3ad2be908 100644 --- a/lib/middlewares/policy.js +++ b/lib/middlewares/policy.js @@ -3,6 +3,7 @@ */ import path from 'path'; import responses from '../helpers/responses.js'; +import logger from '../services/logger.js'; const methodToAction = { get: 'read', post: 'create', put: 'update', patch: 'update', delete: 'delete', @@ -255,9 +256,16 @@ const discoverPolicies = async (policyPaths) => { } // Call subject registration functions exported by module policy files if (normalizedKey.endsWith('subjectregistration')) { + entry.hasSubjectRegistration = true; value({ registerDocumentSubject, registerPathSubject }); } } + // Warn if the module exports abilities/guestAbilities but no SubjectRegistration — document subjects will not be resolved + if ((entry.abilities || entry.guestAbilities) && !entry.hasSubjectRegistration) { + logger.warn( + `[policy] ${path.basename(policyPath)}: exports abilities/guestAbilities but no SubjectRegistration — document subjects will not be resolved`, + ); + } // Also support old invokeRolesPolicies pattern during transition — skip if new-style exports found if (entry.abilities || entry.guestAbilities) { registerAbilities(entry); diff --git a/lib/middlewares/tests/fixtures/policy-abilities-only.js b/lib/middlewares/tests/fixtures/policy-abilities-only.js new file mode 100644 index 000000000..fd61f7436 --- /dev/null +++ b/lib/middlewares/tests/fixtures/policy-abilities-only.js @@ -0,0 +1,12 @@ +// Fixture: policy file with abilities but no SubjectRegistration +/** + * Define task abilities for authenticated users (fixture without SubjectRegistration). + * @param {Object} _user - The authenticated user (unused in fixture) + * @param {Object} _membership - Optional organization membership (unused in fixture) + * @param {Object} context - CASL ability builder context + * @param {Function} context.can - Function to grant abilities + * @returns {void} + */ +export function taskAbilities(_user, _membership, { can }) { + can('read', 'Task'); +} diff --git a/lib/middlewares/tests/fixtures/policy-guest-abilities-only.js b/lib/middlewares/tests/fixtures/policy-guest-abilities-only.js new file mode 100644 index 000000000..22e56df28 --- /dev/null +++ b/lib/middlewares/tests/fixtures/policy-guest-abilities-only.js @@ -0,0 +1,10 @@ +// Fixture: policy file with guestAbilities but no SubjectRegistration +/** + * Define task abilities for guest users (fixture without SubjectRegistration). + * @param {Object} context - CASL ability builder context + * @param {Function} context.can - Function to grant abilities + * @returns {void} + */ +export function taskGuestAbilities({ can }) { + can('read', 'Task'); +} diff --git a/lib/middlewares/tests/fixtures/policy-registration-only.js b/lib/middlewares/tests/fixtures/policy-registration-only.js new file mode 100644 index 000000000..ed150fa0d --- /dev/null +++ b/lib/middlewares/tests/fixtures/policy-registration-only.js @@ -0,0 +1,10 @@ +// Fixture: policy file with SubjectRegistration only (no abilities) +/** + * Register task document subject for CASL resolution. + * @param {Object} context - Registration context + * @param {Function} context.registerDocumentSubject - Function to register document subjects + * @returns {void} + */ +export function taskSubjectRegistration({ registerDocumentSubject }) { + registerDocumentSubject('task', 'Task'); +} diff --git a/lib/middlewares/tests/fixtures/policy-with-registration.js b/lib/middlewares/tests/fixtures/policy-with-registration.js new file mode 100644 index 000000000..900b2445c --- /dev/null +++ b/lib/middlewares/tests/fixtures/policy-with-registration.js @@ -0,0 +1,22 @@ +// Fixture: policy file with abilities AND SubjectRegistration +/** + * Define task abilities for authenticated users. + * @param {Object} _user - The authenticated user (unused in fixture) + * @param {Object} _membership - Optional organization membership (unused in fixture) + * @param {Object} context - CASL ability builder context + * @param {Function} context.can - Function to grant abilities + * @returns {void} + */ +export function taskAbilities(_user, _membership, { can }) { + can('read', 'Task'); +} + +/** + * Register task document subject for CASL resolution. + * @param {Object} context - Registration context + * @param {Function} context.registerDocumentSubject - Function to register document subjects + * @returns {void} + */ +export function taskSubjectRegistration({ registerDocumentSubject }) { + registerDocumentSubject('task', 'Task'); +} diff --git a/lib/middlewares/tests/policy.unit.tests.js b/lib/middlewares/tests/policy.unit.tests.js new file mode 100644 index 000000000..ec6fc1681 --- /dev/null +++ b/lib/middlewares/tests/policy.unit.tests.js @@ -0,0 +1,72 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Mock logger before importing policy +jest.unstable_mockModule('../../services/logger.js', () => ({ + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +// Mock @casl/ability +jest.unstable_mockModule('@casl/ability', () => ({ + AbilityBuilder: jest.fn().mockImplementation(() => ({ + can: jest.fn(), + cannot: jest.fn(), + build: jest.fn().mockReturnValue({ can: jest.fn().mockReturnValue(true) }), + })), + Ability: jest.fn(), + subject: jest.fn((type, doc) => doc), +})); + +const { default: logger } = await import('../../services/logger.js'); +const { default: policy } = await import('../policy.js'); + +/** + * Build absolute path to a test fixture file. + * @param {string} name - Fixture filename + * @returns {string} Absolute path to the fixture + */ +const fixture = (name) => path.join(__dirname, 'fixtures', name); + +describe('policy discoverPolicies unit tests:', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should warn when a policy file exports abilities/guestAbilities but no SubjectRegistration', async () => { + await policy.discoverPolicies([fixture('policy-abilities-only.js')]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('exports abilities/guestAbilities but no SubjectRegistration'), + ); + }); + + test('should not warn when a policy file exports both abilities and SubjectRegistration', async () => { + await policy.discoverPolicies([fixture('policy-with-registration.js')]); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('should not warn when a policy file exports only SubjectRegistration without abilities', async () => { + await policy.discoverPolicies([fixture('policy-registration-only.js')]); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('should warn when a policy file exports guestAbilities but no SubjectRegistration', async () => { + await policy.discoverPolicies([fixture('policy-guest-abilities-only.js')]); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('exports abilities/guestAbilities but no SubjectRegistration'), + ); + }); +});