Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/controllers/jobsController.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const Job = require('../models/jobs');
const JobPositionCategory = require('../models/jobPositionCategory');
const { stripHtml } = require('../utilities/htmlContentSanitizer');

/* ============================================================
UTILS
Expand Down Expand Up @@ -231,7 +232,7 @@ const createJob = async (req, res) => {
const newJob = new Job({
title,
category,
description,
description: typeof description === 'string' ? stripHtml(description) : description,
imageUrl,
location,
applyLink,
Expand All @@ -253,7 +254,12 @@ const updateJob = async (req, res) => {
const { id } = req.params;

try {
const updatedJob = await Job.findByIdAndUpdate(id, req.body, { new: true });
const updates = { ...req.body };
if (typeof updates.description === 'string') {
updates.description = stripHtml(updates.description);
}

const updatedJob = await Job.findByIdAndUpdate(id, updates, { new: true });
if (!updatedJob) return res.status(404).json({ error: 'Job not found' });

res.json(updatedJob);
Expand Down
36 changes: 36 additions & 0 deletions src/controllers/jobsController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ describe('jobsController', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining(updateData));
});

it('updateJob: should store the description as plain text', async () => {
Job.findByIdAndUpdate.mockResolvedValue({ _id: jobId });

await updateJob(
{
params: { id: jobId },
body: { description: '<p>Build <strong>useful</strong> tools</p>' },
},
res,
);

expect(Job.findByIdAndUpdate).toHaveBeenCalledWith(
jobId,
{ description: 'Build useful tools' },
{ new: true },
);
});

it('deleteJob: should delete successfully', async () => {
Job.findByIdAndDelete.mockResolvedValue({ _id: jobId });
await deleteJob({ params: { id: jobId } }, res);
Expand Down Expand Up @@ -255,6 +273,24 @@ describe('jobsController', () => {
expect(res.json).toHaveBeenCalledWith(savedJob);
});

it('stores a new job description as plain text', async () => {
Job.findOne.mockReturnValue({ sort: jest.fn().mockResolvedValue(null) });
const saveSpy = jest.spyOn(Job.prototype, 'save').mockResolvedValue({ _id: 'job1' });

await createJob(
{
body: {
...newJobBody,
description: '<p>Build <strong>useful</strong> tools</p><script>bad()</script>',
},
},
res,
);

const savedInstance = saveSpy.mock.instances.at(-1);
expect(savedInstance.description).toBe('Build useful tools');
});

it('defaults displayOrder to 0 when no jobs exist yet', async () => {
Job.findOne.mockReturnValue({ sort: jest.fn().mockResolvedValue(null) });
const savedJob = { _id: 'job1', ...newJobBody, displayOrder: 0 };
Expand Down
23 changes: 22 additions & 1 deletion src/utilities/__tests__/htmlContentSanitizer.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { cleanHtml } = require('../htmlContentSanitizer');
const { cleanHtml, stripHtml } = require('../htmlContentSanitizer');

describe('htmlContentSanitizer', () => {
it('should sanitize HTML content', () => {
Expand Down Expand Up @@ -56,3 +56,24 @@ describe('htmlContentSanitizer', () => {
expect(clean).toBe('<p>Test</p>');
});
});

describe('stripHtml', () => {
it('removes markup while preserving readable block boundaries', () => {
const dirty = '<p>Hello <strong>world</strong></p><ul><li>One</li><li>Two</li></ul>';

expect(stripHtml(dirty)).toBe('Hello world\nOne\nTwo');
});

it('removes script content and decodes HTML entities', () => {
const dirty = '<script>alert("xss")</script><p>Safe &amp; sound; 2 &lt; 3</p>';

expect(stripHtml(dirty)).toBe('Safe & sound; 2 < 3');
});

it('handles plain, empty, and missing values', () => {
expect(stripHtml('Just plain text')).toBe('Just plain text');
expect(stripHtml('')).toBe('');
expect(stripHtml(null)).toBe('');
expect(stripHtml(undefined)).toBe('');
});
});
23 changes: 22 additions & 1 deletion src/utilities/htmlContentSanitizer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const sanitizeHtml = require('sanitize-html');
const cheerio = require('cheerio');

// Please refer to https://www.npmjs.com/package/sanitize-html?activeTab=readme for more information.
// eslint-disable-next-line import/prefer-default-export
Expand All @@ -8,6 +9,26 @@ const cleanHtml = (dirty) =>
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
});

const stripHtml = (dirty) => {
if (dirty === null || dirty === undefined) return '';

const textWithLineBreaks = String(dirty)
.replace(/<br\b[^>]*>/gi, '\n')
.replace(/<\/(?:p|div|li|h[1-6]|tr|blockquote)\s*>/gi, '\n');
const sanitizedText = sanitizeHtml(textWithLineBreaks, {
allowedTags: [],
allowedAttributes: {},
});
const decodedText = cheerio.load(`<body>${sanitizedText}</body>`)('body').text();

return decodedText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
};

module.exports = {
cleanHtml,
};
stripHtml,
};
Loading