Skip to content

Commit 3337568

Browse files
fix(policy): startup warn for missing SubjectRegistration + mailer test coverage (#3397)
* fix(policy): warn at startup if module has abilities without SubjectRegistration (#3395) Track hasSubjectRegistration per entry during discoverPolicies; emit a logger.warn when abilities/guestAbilities are exported but no *SubjectRegistration function is found, so misconfigured modules are caught at startup instead of silently failing CASL document-level checks. * test(mailer): add Buffer attachment and empty filename coverage (#3396) Add test for Buffer content encoding to base64 in ResendProvider, and a test that validates sendMail rejects attachments with an empty string filename. * fix(jest): exclude tests/fixtures/ dirs from testPathIgnorePatterns * fix(jest): exclude tests/fixtures from coverage collection * fix(policy): improve warn message wording, add JSDoc to fixtures, prefix unused params
1 parent 53e7cad commit 3337568

9 files changed

Lines changed: 169 additions & 1 deletion

jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export default {
5757
'!<rootDir>/modules/**/migrations/**',
5858
// Exclude static upload config — just object literals, like config/defaults
5959
'!<rootDir>/modules/uploads/config/config.uploads.js',
60+
// Exclude test fixtures — helper stubs used by tests, not production code
61+
'!<rootDir>/**/tests/fixtures/**',
6062
],
6163
// The directory where Jest should output its coverage files
6264
coverageDirectory: 'coverage',
@@ -180,7 +182,7 @@ export default {
180182
testMatch: ['<rootDir>/modules/*/tests/**/*.js', '<rootDir>/lib/**/tests/**/*.js'],
181183

182184
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
183-
testPathIgnorePatterns: ['/node_modules/'],
185+
testPathIgnorePatterns: ['/node_modules/', '/tests/fixtures/'],
184186

185187
// The regexp pattern Jest uses to detect test files
186188
// testRegex: "",

lib/helpers/mailer/tests/mailer.unit.tests.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ describe('mailer index with resend provider unit tests:', () => {
131131
).rejects.toThrow('exceeds 25 MB limit');
132132
});
133133

134+
test('should throw when attachment filename is an empty string', async () => {
135+
await expect(
136+
mailer.sendMail({
137+
to: 'user@example.com',
138+
subject: 'Test',
139+
template: 'welcome',
140+
params: { name: 'Bob' },
141+
attachments: [{ filename: '', content: 'data' }],
142+
}),
143+
).rejects.toThrow('Attachment filename must be a non-empty string');
144+
});
145+
134146
test('should throw when attachment filename is not a string', async () => {
135147
await expect(
136148
mailer.sendMail({

lib/helpers/mailer/tests/provider.resend.unit.tests.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,26 @@ describe('ResendProvider unit tests:', () => {
8484
expect(result).toEqual(mockData);
8585
});
8686

87+
test('should encode Buffer attachment content to base64', async () => {
88+
const buffer = Buffer.from('binary data');
89+
const mockData = { id: 'email_buf' };
90+
sendMock.mockResolvedValue({ data: mockData, error: null });
91+
92+
await provider.send({
93+
from: 'from@test.com',
94+
to: 'to@test.com',
95+
subject: 'Test',
96+
html: '<p>test</p>',
97+
attachments: [{ filename: 'data.bin', content: buffer }],
98+
});
99+
100+
expect(sendMock).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
attachments: [{ filename: 'data.bin', content: Buffer.from(buffer).toString('base64') }],
103+
}),
104+
);
105+
});
106+
87107
test('should throw on Resend API error', async () => {
88108
sendMock.mockResolvedValue({ data: null, error: { message: 'Invalid API key' } });
89109

lib/middlewares/policy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import path from 'path';
55
import responses from '../helpers/responses.js';
6+
import logger from '../services/logger.js';
67

78
const methodToAction = {
89
get: 'read', post: 'create', put: 'update', patch: 'update', delete: 'delete',
@@ -255,9 +256,16 @@ const discoverPolicies = async (policyPaths) => {
255256
}
256257
// Call subject registration functions exported by module policy files
257258
if (normalizedKey.endsWith('subjectregistration')) {
259+
entry.hasSubjectRegistration = true;
258260
value({ registerDocumentSubject, registerPathSubject });
259261
}
260262
}
263+
// Warn if the module exports abilities/guestAbilities but no SubjectRegistration — document subjects will not be resolved
264+
if ((entry.abilities || entry.guestAbilities) && !entry.hasSubjectRegistration) {
265+
logger.warn(
266+
`[policy] ${path.basename(policyPath)}: exports abilities/guestAbilities but no SubjectRegistration — document subjects will not be resolved`,
267+
);
268+
}
261269
// Also support old invokeRolesPolicies pattern during transition — skip if new-style exports found
262270
if (entry.abilities || entry.guestAbilities) {
263271
registerAbilities(entry);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Fixture: policy file with abilities but no SubjectRegistration
2+
/**
3+
* Define task abilities for authenticated users (fixture without SubjectRegistration).
4+
* @param {Object} _user - The authenticated user (unused in fixture)
5+
* @param {Object} _membership - Optional organization membership (unused in fixture)
6+
* @param {Object} context - CASL ability builder context
7+
* @param {Function} context.can - Function to grant abilities
8+
* @returns {void}
9+
*/
10+
export function taskAbilities(_user, _membership, { can }) {
11+
can('read', 'Task');
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Fixture: policy file with guestAbilities but no SubjectRegistration
2+
/**
3+
* Define task abilities for guest users (fixture without SubjectRegistration).
4+
* @param {Object} context - CASL ability builder context
5+
* @param {Function} context.can - Function to grant abilities
6+
* @returns {void}
7+
*/
8+
export function taskGuestAbilities({ can }) {
9+
can('read', 'Task');
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Fixture: policy file with SubjectRegistration only (no abilities)
2+
/**
3+
* Register task document subject for CASL resolution.
4+
* @param {Object} context - Registration context
5+
* @param {Function} context.registerDocumentSubject - Function to register document subjects
6+
* @returns {void}
7+
*/
8+
export function taskSubjectRegistration({ registerDocumentSubject }) {
9+
registerDocumentSubject('task', 'Task');
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Fixture: policy file with abilities AND SubjectRegistration
2+
/**
3+
* Define task abilities for authenticated users.
4+
* @param {Object} _user - The authenticated user (unused in fixture)
5+
* @param {Object} _membership - Optional organization membership (unused in fixture)
6+
* @param {Object} context - CASL ability builder context
7+
* @param {Function} context.can - Function to grant abilities
8+
* @returns {void}
9+
*/
10+
export function taskAbilities(_user, _membership, { can }) {
11+
can('read', 'Task');
12+
}
13+
14+
/**
15+
* Register task document subject for CASL resolution.
16+
* @param {Object} context - Registration context
17+
* @param {Function} context.registerDocumentSubject - Function to register document subjects
18+
* @returns {void}
19+
*/
20+
export function taskSubjectRegistration({ registerDocumentSubject }) {
21+
registerDocumentSubject('task', 'Task');
22+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
import { fileURLToPath } from 'url';
6+
import path from 'path';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
10+
// Mock logger before importing policy
11+
jest.unstable_mockModule('../../services/logger.js', () => ({
12+
default: {
13+
warn: jest.fn(),
14+
info: jest.fn(),
15+
error: jest.fn(),
16+
},
17+
}));
18+
19+
// Mock @casl/ability
20+
jest.unstable_mockModule('@casl/ability', () => ({
21+
AbilityBuilder: jest.fn().mockImplementation(() => ({
22+
can: jest.fn(),
23+
cannot: jest.fn(),
24+
build: jest.fn().mockReturnValue({ can: jest.fn().mockReturnValue(true) }),
25+
})),
26+
Ability: jest.fn(),
27+
subject: jest.fn((type, doc) => doc),
28+
}));
29+
30+
const { default: logger } = await import('../../services/logger.js');
31+
const { default: policy } = await import('../policy.js');
32+
33+
/**
34+
* Build absolute path to a test fixture file.
35+
* @param {string} name - Fixture filename
36+
* @returns {string} Absolute path to the fixture
37+
*/
38+
const fixture = (name) => path.join(__dirname, 'fixtures', name);
39+
40+
describe('policy discoverPolicies unit tests:', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
test('should warn when a policy file exports abilities/guestAbilities but no SubjectRegistration', async () => {
46+
await policy.discoverPolicies([fixture('policy-abilities-only.js')]);
47+
48+
expect(logger.warn).toHaveBeenCalledWith(
49+
expect.stringContaining('exports abilities/guestAbilities but no SubjectRegistration'),
50+
);
51+
});
52+
53+
test('should not warn when a policy file exports both abilities and SubjectRegistration', async () => {
54+
await policy.discoverPolicies([fixture('policy-with-registration.js')]);
55+
56+
expect(logger.warn).not.toHaveBeenCalled();
57+
});
58+
59+
test('should not warn when a policy file exports only SubjectRegistration without abilities', async () => {
60+
await policy.discoverPolicies([fixture('policy-registration-only.js')]);
61+
62+
expect(logger.warn).not.toHaveBeenCalled();
63+
});
64+
65+
test('should warn when a policy file exports guestAbilities but no SubjectRegistration', async () => {
66+
await policy.discoverPolicies([fixture('policy-guest-abilities-only.js')]);
67+
68+
expect(logger.warn).toHaveBeenCalledWith(
69+
expect.stringContaining('exports abilities/guestAbilities but no SubjectRegistration'),
70+
);
71+
});
72+
});

0 commit comments

Comments
 (0)