@opentermsarchive/engine 15.3.1 → 16.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import { expect, use } from 'chai';
2
+ import sinon from 'sinon';
3
+ import sinonChai from 'sinon-chai';
4
+
5
+ import logger, { createModuleLogger } from './index.js';
6
+
7
+ use(sinonChai);
8
+
9
+ describe('Dataset logger', () => {
10
+ describe('#createModuleLogger', () => {
11
+ const MODULE_NAME = 'test-module';
12
+ let moduleLogger;
13
+
14
+ before(() => {
15
+ moduleLogger = createModuleLogger(MODULE_NAME);
16
+ });
17
+
18
+ afterEach(() => {
19
+ sinon.restore();
20
+ });
21
+
22
+ [ 'debug', 'info', 'warn', 'error' ].forEach(level => {
23
+ it(`forwards ${level} messages to the logger with the module name`, () => {
24
+ const stub = sinon.stub(logger, level);
25
+
26
+ moduleLogger[level]('message');
27
+
28
+ expect(stub).to.have.been.calledOnceWithExactly('message', { module: MODULE_NAME });
29
+ });
30
+ });
31
+ });
32
+ });
@@ -8,7 +8,6 @@ import { createModuleLogger } from '../../logger/index.js';
8
8
 
9
9
  const logger = createModuleLogger('datagouv');
10
10
 
11
- const DATASET_LICENSE = 'odc-odbl';
12
11
  const DEFAULT_RESOURCE_DESCRIPTION = 'See README.md inside the archive for dataset structure and usage information.';
13
12
 
14
13
  const routes = {
@@ -112,11 +111,11 @@ export async function createDataset({ apiBaseUrl, headers, organizationId, title
112
111
  return dataset;
113
112
  }
114
113
 
115
- export async function updateDatasetMetadata({ apiBaseUrl, headers, datasetId, title, description, stats, frequency }) {
114
+ export async function updateDatasetMetadata({ apiBaseUrl, headers, datasetId, title, description, license, stats, frequency }) {
116
115
  const updatePayload = {
117
116
  title,
118
117
  description,
119
- license: DATASET_LICENSE,
118
+ license,
120
119
  frequency,
121
120
  };
122
121
 
@@ -1,5 +1,6 @@
1
1
  import config from 'config';
2
2
 
3
+ import * as license from '../../../../src/dataset/license.js';
3
4
  import * as readme from '../../assets/README.template.js';
4
5
  import { createModuleLogger } from '../../logger/index.js';
5
6
 
@@ -9,7 +10,6 @@ const logger = createModuleLogger('datagouv');
9
10
 
10
11
  const PRODUCTION_API_BASE_URL = 'https://www.data.gouv.fr/api/1';
11
12
  const DEMO_API_BASE_URL = 'https://demo.data.gouv.fr/api/1';
12
- const DATASET_LICENSE = 'odc-odbl';
13
13
 
14
14
  export default async function publish({ archivePath, stats }) {
15
15
  const { datasetId, organizationIdOrSlug, apiBaseUrl, headers, datasetTitle, frequency } = loadConfiguration();
@@ -19,7 +19,7 @@ export default async function publish({ archivePath, stats }) {
19
19
  ? await getDataset({ apiBaseUrl, headers, datasetId })
20
20
  : await ensureDatasetExists({ apiBaseUrl, headers, organizationIdOrSlug, datasetTitle, description, frequency });
21
21
 
22
- await updateDatasetMetadata({ apiBaseUrl, headers, datasetId: dataset.id, title: datasetTitle, description, stats, frequency });
22
+ await updateDatasetMetadata({ apiBaseUrl, headers, datasetId: dataset.id, title: datasetTitle, description, license: license.DATAGOUV_ID, stats, frequency });
23
23
 
24
24
  const { resourceId, fileName } = await handleResourceUpload({ apiBaseUrl, headers, datasetId: dataset.id, dataset, archivePath });
25
25
 
@@ -73,7 +73,7 @@ async function ensureDatasetExists({ apiBaseUrl, headers, organizationIdOrSlug,
73
73
  let dataset = await findDatasetByTitle({ apiBaseUrl, headers, organizationId: organization.id, title: datasetTitle });
74
74
 
75
75
  if (!dataset) {
76
- dataset = await createDataset({ apiBaseUrl, headers, organizationId: organization.id, title: datasetTitle, description, license: DATASET_LICENSE, frequency });
76
+ dataset = await createDataset({ apiBaseUrl, headers, organizationId: organization.id, title: datasetTitle, description, license: license.DATAGOUV_ID, frequency });
77
77
  }
78
78
 
79
79
  return dataset;
@@ -51,7 +51,8 @@ export default async function publish({
51
51
  projectId = null;
52
52
  }
53
53
 
54
- const tagName = `${path.basename(archivePath, path.extname(archivePath))}`; // use archive filename as Git tag
54
+ const archiveFilename = path.basename(archivePath);
55
+ const tagName = path.basename(archiveFilename, path.extname(archiveFilename)); // use archive filename as Git tag
55
56
 
56
57
  try {
57
58
  let options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN);
@@ -88,7 +89,7 @@ export default async function publish({
88
89
  // restrict characters to the ones allowed by GitLab APIs
89
90
  const packageName = config.get('@opentermsarchive/engine.dataset.title').replace(/[^a-zA-Z0-9.\-_]/g, '-');
90
91
  const packageVersion = tagName.replace(/[^a-zA-Z0-9.\-_]/g, '-');
91
- const packageFileName = archivePath.replace(/[^a-zA-Z0-9.\-_/]/g, '-');
92
+ const packageFileName = archiveFilename.replace(/[^a-zA-Z0-9.\-_]/g, '-');
92
93
 
93
94
  logger.debug(`packageName: ${packageName}, packageVersion: ${packageVersion} packageFileName: ${packageFileName}`);
94
95
 
@@ -108,9 +109,9 @@ export default async function publish({
108
109
  // Create the release and link the package
109
110
  const formData = new FormData();
110
111
 
111
- formData.append('name', archivePath);
112
+ formData.append('name', archiveFilename);
112
113
  formData.append('url', publishedPackageUrl);
113
- formData.append('file', fsApi.createReadStream(archivePath), { filename: path.basename(archivePath) });
114
+ formData.append('file', fsApi.createReadStream(archivePath), { filename: archiveFilename });
114
115
 
115
116
  options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN);
116
117
  options.method = 'POST';
@@ -0,0 +1,90 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { expect } from 'chai';
6
+ import config from 'config';
7
+ import nock from 'nock';
8
+
9
+ import publish from './index.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const { origin: API_ORIGIN, pathname: API_PATH } = new URL(config.get('@opentermsarchive/engine.dataset.apiBaseURL'));
14
+ const PROJECT_PATH = 'OpenTermsArchive/sandbox-versions';
15
+ const PROJECT_ID = 1;
16
+ const ARCHIVE_NAME = 'sandbox-2026-01-01';
17
+ const ARCHIVE_FILENAME = `${ARCHIVE_NAME}.zip`;
18
+ const TMP_PATH = path.resolve(__dirname, './tmp');
19
+ const ARCHIVE_PATH = path.join(TMP_PATH, ARCHIVE_FILENAME);
20
+ const DIRECT_ASSET_URL = 'https://gitlab.example.test/download/42';
21
+ const STATS = {
22
+ servicesCount: 2,
23
+ firstVersionDate: new Date('2021-01-01T00:00:00Z'),
24
+ lastVersionDate: new Date('2022-01-06T00:00:00Z'),
25
+ };
26
+
27
+ describe('GitLab dataset publisher', () => {
28
+ describe('#publish', () => {
29
+ let packageUploadScope;
30
+ let assetLinkScope;
31
+ let result;
32
+ let previousToken;
33
+
34
+ before(async function () {
35
+ this.timeout(5000);
36
+ previousToken = process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN;
37
+ process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN = 'token';
38
+
39
+ await fs.mkdir(TMP_PATH, { recursive: true });
40
+ await fs.writeFile(ARCHIVE_PATH, 'archive content'); // Plain text keeps the multipart body inspectable: nock hands binary bodies to matchers as hex strings
41
+
42
+ nock(API_ORIGIN)
43
+ .get(`${API_PATH}/projects/${encodeURIComponent(PROJECT_PATH)}`)
44
+ .reply(200, { id: PROJECT_ID });
45
+
46
+ nock(API_ORIGIN)
47
+ .post(`${API_PATH}/projects/${PROJECT_ID}/releases`)
48
+ .reply(201, { commit: { id: 'sha' } });
49
+
50
+ packageUploadScope = nock(API_ORIGIN)
51
+ .put(`${API_PATH}/projects/${PROJECT_ID}/packages/generic/sandbox/${ARCHIVE_NAME}/${ARCHIVE_FILENAME}`)
52
+ .query({ status: 'default', select: 'package_file' })
53
+ .reply(201, { id: 42 });
54
+
55
+ assetLinkScope = nock(API_ORIGIN)
56
+ .post(`${API_PATH}/projects/${PROJECT_ID}/releases/${ARCHIVE_NAME}/assets/links`, body => body.includes(`name="name"\r\n\r\n${ARCHIVE_FILENAME}\r\n`))
57
+ .reply(201, { direct_asset_url: DIRECT_ASSET_URL });
58
+
59
+ result = await publish({
60
+ archivePath: ARCHIVE_PATH,
61
+ releaseDate: new Date('2026-01-01T08:30:00Z'),
62
+ stats: STATS,
63
+ });
64
+ });
65
+
66
+ after(async () => {
67
+ nock.cleanAll();
68
+
69
+ if (previousToken === undefined) {
70
+ delete process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN;
71
+ } else {
72
+ process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN = previousToken;
73
+ }
74
+
75
+ await fs.rm(TMP_PATH, { recursive: true, force: true });
76
+ });
77
+
78
+ it('uploads the package under the archive file name', () => {
79
+ expect(packageUploadScope.isDone()).to.be.true;
80
+ });
81
+
82
+ it('links the release asset under the archive file name', () => {
83
+ expect(assetLinkScope.isDone()).to.be.true;
84
+ });
85
+
86
+ it('returns the direct asset URL', () => {
87
+ expect(result).to.equal(DIRECT_ASSET_URL);
88
+ });
89
+ });
90
+ });
@@ -1,4 +1,5 @@
1
- import puppeteer from 'puppeteer-extra';
1
+ import puppeteer from 'puppeteer';
2
+ import { addExtra } from 'puppeteer-extra';
2
3
  import stealthPlugin from 'puppeteer-extra-plugin-stealth';
3
4
  import navigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';
4
5
  import userAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
@@ -39,7 +40,7 @@ export default async function fetch(url, cssSelectors, config) {
39
40
  page = await context.newPage();
40
41
  client = await page.createCDPSession();
41
42
 
42
- await configurePage(page, config);
43
+ await configurePage(page, client, config);
43
44
 
44
45
  const selectors = [].concat(cssSelectors).filter(Boolean);
45
46
 
@@ -47,7 +48,7 @@ export default async function fetch(url, cssSelectors, config) {
47
48
  let handled = null;
48
49
 
49
50
  if (!selectors.length) { // CSS selectors are specified only for HTML content and omitted when fetching a PDF
50
- ({ pdf, handled } = setupPdfInterception(client));
51
+ ({ pdf, handled } = await setupPdfInterception(client));
51
52
  }
52
53
 
53
54
  let response;
@@ -125,13 +126,13 @@ export async function launchHeadlessBrowser(language) {
125
126
  }
126
127
 
127
128
  const { locale, languages } = parseLanguage(language);
129
+ const puppeteerExtra = addExtra(puppeteer); // Fresh instance for each launch, as plugins registered on the shared instance accumulate across launches and their hooks would run once per copy on every page
128
130
  const stealth = stealthPlugin();
129
131
 
130
132
  stealth.enabledEvasions.delete('user-agent-override');
131
133
  stealth.enabledEvasions.delete('navigator.languages');
132
- puppeteer.use(stealth);
133
- puppeteer.use(userAgentOverride({ locale }));
134
- puppeteer.use(navigatorLanguages({ languages }));
134
+ puppeteerExtra.use(stealth);
135
+ puppeteerExtra.use(navigatorLanguages({ languages }));
135
136
 
136
137
  const options = {
137
138
  args: [],
@@ -156,12 +157,21 @@ export async function launchHeadlessBrowser(language) {
156
157
  options.args.push('--disable-setuid-sandbox');
157
158
  }
158
159
 
159
- browser = await puppeteer.launch(options);
160
+ const launchedBrowser = await puppeteerExtra.launch(options);
161
+
162
+ try {
163
+ launchedBrowser.userAgentOverride = await captureUserAgentOverride(launchedBrowser, locale); // Computed once since the user agent is the same for every page of the browser
164
+ } catch (error) {
165
+ await launchedBrowser.close().catch(() => {});
166
+ throw error;
167
+ }
160
168
 
161
169
  if (proxyCredentials) {
162
- browser.proxyCredentials = proxyCredentials;
170
+ launchedBrowser.proxyCredentials = proxyCredentials;
163
171
  }
164
172
 
173
+ browser = launchedBrowser; // Set only once fully configured, so that no caller gets a browser without its user agent override
174
+
165
175
  return browser;
166
176
  }
167
177
 
@@ -184,22 +194,44 @@ function isValidHttpStatus(status) {
184
194
  return (status >= 200 && status < 300) || status === 304;
185
195
  }
186
196
 
187
- async function configurePage(page, config) {
197
+ async function configurePage(page, client, config) {
188
198
  await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
189
199
  await page.setDefaultNavigationTimeout(config.navigationTimeout);
200
+ await client.send('Network.setUserAgentOverride', browser.userAgentOverride);
190
201
 
191
202
  if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
192
203
  await page.authenticate(browser.proxyCredentials);
193
204
  }
194
205
  }
195
206
 
196
- function setupPdfInterception(client) {
207
+ async function captureUserAgentOverride(browser, locale) { // The stealth evasion sends the override without awaiting it, so a failure would crash the process on an unhandled rejection; running it once on a stand-in page records the override for the fetcher to send itself
208
+ const evasion = userAgentOverride({ locale });
209
+ let override;
210
+
211
+ await evasion.beforeLaunch({ headless: true }); // Otherwise the evasion leaves Accept-Language to browser preferences
212
+ await evasion.onPageCreated({ // The stand-in page exposes only what the evasion reads
213
+ browser: () => browser,
214
+ _client: () => ({
215
+ send: (method, params) => {
216
+ if (method === 'Network.setUserAgentOverride') {
217
+ override = params;
218
+ }
219
+ },
220
+ }),
221
+ });
222
+
223
+ if (!override?.userAgent) {
224
+ throw new Error('Could not capture the user agent override from the stealth user-agent-override evasion');
225
+ }
226
+
227
+ return override;
228
+ }
229
+
230
+ async function setupPdfInterception(client) {
197
231
  const pdf = { content: null, status: null };
198
232
  let onHandled;
199
233
  const handled = new Promise(resolve => { onHandled = resolve; });
200
234
 
201
- client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it's handled by the PDF viewer
202
-
203
235
  client.on('Fetch.requestPaused', async ({ requestId, resourceType, responseHeaders, responseStatusCode }) => {
204
236
  try {
205
237
  const contentType = responseHeaders?.find(header => header.name.toLowerCase() === 'content-type')?.value;
@@ -234,6 +266,8 @@ function setupPdfInterception(client) {
234
266
  }
235
267
  });
236
268
 
269
+ await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it is handled by the PDF viewer; enabled once the listener is registered so that no paused request is missed
270
+
237
271
  return { pdf, handled };
238
272
  }
239
273
 
@@ -17,7 +17,7 @@ use(chaiAsPromised);
17
17
  const dynamicHTML = '<!DOCTYPE html><html><head><title>Dynamic Page</title><script>setTimeout(() => { document.body.innerHTML += "<div class=\'dynamic\'>Loaded</div>"; }, 100);</script></head><body></body></html>';
18
18
  const delayedContentHTML = '<!DOCTYPE html><html><head><title>Delayed Content</title><script>setTimeout(() => { document.querySelector(".content").textContent = "Final content"; }, 100);</script></head><body><div class="content"></div></body></html>';
19
19
  const langEchoHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-language", navigator.language); document.body.setAttribute("data-languages", navigator.languages.join(","));</script></body></html>';
20
- const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
20
+ const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-platform", navigator.platform); document.body.setAttribute("data-ua-brands", navigator.userAgentData ? navigator.userAgentData.brands.map(entry => entry.brand).join(",") : ""); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
21
21
 
22
22
  describe('Full DOM Fetcher', function () {
23
23
  this.timeout(60000);
@@ -40,6 +40,11 @@ describe('Full DOM Fetcher', function () {
40
40
 
41
41
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body data-accept-language="${acceptLanguage}"></body></html>`);
42
42
  }
43
+ if (request.url === '/client-hints-header') {
44
+ const clientHintsBrands = request.headers['sec-ch-ua'] || '';
45
+
46
+ response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body>${clientHintsBrands}</body></html>`);
47
+ }
43
48
  if (request.url === '/lang-echo') {
44
49
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(langEchoHTML);
45
50
  }
@@ -76,6 +81,16 @@ describe('Full DOM Fetcher', function () {
76
81
 
77
82
  expect(browser1).to.equal(browser2);
78
83
  });
84
+
85
+ it('registers plugin hooks once per launch', async () => {
86
+ const initialListenerCount = (await launchHeadlessBrowser('en')).listenerCount('targetcreated');
87
+
88
+ await stopHeadlessBrowser();
89
+
90
+ const relaunchedBrowser = await launchHeadlessBrowser('en');
91
+
92
+ expect(relaunchedBrowser.listenerCount('targetcreated')).to.equal(initialListenerCount);
93
+ });
79
94
  });
80
95
 
81
96
  describe('#fetch', () => {
@@ -136,6 +151,25 @@ describe('Full DOM Fetcher', function () {
136
151
  expect(content.equals(expectedPDFContent)).to.be.true;
137
152
  });
138
153
  });
154
+
155
+ context('when the user agent override cannot be applied', () => {
156
+ let browser;
157
+ let userAgentOverride;
158
+
159
+ before(async () => {
160
+ browser = await launchHeadlessBrowser('en');
161
+ ({ userAgentOverride } = browser);
162
+ browser.userAgentOverride = {}; // Rejected by Chrome since the user agent is mandatory
163
+ });
164
+
165
+ after(() => {
166
+ browser.userAgentOverride = userAgentOverride;
167
+ });
168
+
169
+ it('rejects with the protocol error', async () => {
170
+ await expect(fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, [], config)).to.be.rejectedWith('Network.setUserAgentOverride');
171
+ });
172
+ });
139
173
  });
140
174
 
141
175
  describe('Language configuration', () => {
@@ -201,6 +235,23 @@ describe('Full DOM Fetcher', function () {
201
235
  expect(content).not.to.match(/HeadlessChrome/);
202
236
  });
203
237
 
238
+ it('removes HeadlessChrome from navigator.userAgentData brands', () => {
239
+ expect(content).to.match(/data-ua-brands="[^"]*Google Chrome[^"]*"/);
240
+ expect(content).not.to.match(/data-ua-brands="[^"]*Headless[^"]*"/);
241
+ });
242
+
243
+ it('sends the Sec-CH-UA header without HeadlessChrome', async () => {
244
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/client-hints-header`, [], config);
245
+
246
+ expect(result.content).to.match(/"Google Chrome";v="\d+"/);
247
+ expect(result.content).not.to.match(/Headless/);
248
+ });
249
+
250
+ it('does not expose a Linux host through navigator.platform', () => {
251
+ expect(content).to.match(/data-platform="[^"]+"/);
252
+ expect(content).not.to.match(/data-platform="Linux/);
253
+ });
254
+
204
255
  it('uses a realistic viewport instead of Puppeteer default', () => {
205
256
  expect(content).to.match(/data-viewport-width="1920"/);
206
257
  expect(content).to.match(/data-viewport-height="1080"/);
@@ -2,5 +2,10 @@ import logger from '../logger.js';
2
2
 
3
3
  export default function errorsMiddleware(err, req, res, next) {
4
4
  logger.error(err.stack);
5
+
6
+ if (res.headersSent) { // A response that already started cannot be restarted with a JSON error; defer to Express' default handler, which ends the request instead of leaving the client waiting
7
+ return next(err);
8
+ }
9
+
5
10
  res.status(500).json({ error: 'Internal Server Error' }); // Never echo internal error details: they can expose server internals such as filesystem paths
6
11
  }
@@ -0,0 +1,56 @@
1
+ import { expect, use } from 'chai';
2
+ import sinon from 'sinon';
3
+ import sinonChai from 'sinon-chai';
4
+
5
+ import errorsMiddleware from './errors.js';
6
+
7
+ use(sinonChai);
8
+
9
+ describe('Errors middleware', () => {
10
+ const error = new Error('Boom');
11
+
12
+ let res;
13
+ let next;
14
+
15
+ beforeEach(() => {
16
+ res = {
17
+ status: sinon.stub().returnsThis(),
18
+ json: sinon.stub(),
19
+ };
20
+ next = sinon.stub();
21
+ });
22
+
23
+ context('when no response has been sent yet', () => {
24
+ beforeEach(() => {
25
+ res.headersSent = false;
26
+ errorsMiddleware(error, {}, res, next);
27
+ });
28
+
29
+ it('responds with 500 status code', () => {
30
+ expect(res.status).to.have.been.calledOnceWith(500);
31
+ });
32
+
33
+ it('responds with a generic JSON error', () => {
34
+ expect(res.json).to.have.been.calledOnceWith({ error: 'Internal Server Error' });
35
+ });
36
+
37
+ it('does not call next', () => {
38
+ expect(next).to.not.have.been.called;
39
+ });
40
+ });
41
+
42
+ context('when headers have already been sent', () => {
43
+ beforeEach(() => {
44
+ res.headersSent = true;
45
+ errorsMiddleware(error, {}, res, next);
46
+ });
47
+
48
+ it('forwards the error to the default Express handler instead of responding again', () => {
49
+ expect(next).to.have.been.calledOnceWith(error);
50
+ });
51
+
52
+ it('does not attempt to set a status code', () => {
53
+ expect(res.status).to.not.have.been.called;
54
+ });
55
+ });
56
+ });