@jrpool/kilotest 50.0.0 → 52.0.0

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.
package/index.test.ts CHANGED
@@ -15,9 +15,23 @@ process.env.DB_DIR = fixtureDBDir;
15
15
  process.env.AUTH_CODE = 'test-auth-code';
16
16
  process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
17
17
  process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
18
+ process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.json');
18
19
  process.env.TESTARO_WORKERS = JSON.stringify({
19
20
  worker1: {secret: 'secret1', name: 'Worker One'}
20
21
  });
22
+ // Allow internal targets by default, so tests that submit an ordinary https://example.com/...
23
+ // URL do not depend on real DNS/network access to pass the resolution check that
24
+ // isAllowedTarget performs. Tests of the check itself (below) override this per test.
25
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
26
+ // Blank the alert configuration unconditionally, so this file sends no real alert
27
+ // emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
28
+ // when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
29
+ // which normally guards against this by --require-ing test/setup.ts first. dotenv does
30
+ // not override an already-set env var, so setting these here, before index.ts is ever
31
+ // imported, prevents a real .env file's alert credentials from ever taking effect.
32
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
33
+ process.env[key] = '';
34
+ }
21
35
 
22
36
  // IMPORTS
23
37
 
@@ -58,6 +72,7 @@ beforeEach(async () => {
58
72
  await fs.mkdir(testCommentsDir, {recursive: true});
59
73
  await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
60
74
  await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
75
+ await fs.writeFile(path.join(testCommentsDir, 'featureRequests.json'), '[]\n');
61
76
  });
62
77
 
63
78
  after(async () => {
@@ -215,6 +230,30 @@ test('GET /index.html serves the home page', async () => {
215
230
  assert.ok(res.headers['content-type'].includes('text/html'));
216
231
  });
217
232
 
233
+ // TESTS: HEAD routes
234
+
235
+ test('HEAD / returns the same status and headers as GET /, with no body', async () => {
236
+ const getRes = await request('GET', '/');
237
+ const headRes = await request('HEAD', '/');
238
+ assert.equal(headRes.statusCode, getRes.statusCode);
239
+ assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
240
+ assert.equal(headRes.body, '');
241
+ });
242
+
243
+ test('HEAD /index.html returns the same status and headers as GET /, with no body', async () => {
244
+ const getRes = await request('GET', '/');
245
+ const headRes = await request('HEAD', '/index.html');
246
+ assert.equal(headRes.statusCode, getRes.statusCode);
247
+ assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
248
+ assert.equal(headRes.body, '');
249
+ });
250
+
251
+ test('HEAD /robots.txt returns an invalid HEAD request error, since only the home page supports HEAD', async () => {
252
+ const res = await request('HEAD', '/robots.txt');
253
+ assert.equal(res.statusCode, 400);
254
+ assert.equal(res.body, '');
255
+ });
256
+
218
257
  test('GET /robots.txt serves the robots file as text', async () => {
219
258
  const res = await request('GET', '/robots.txt');
220
259
  assert.equal(res.statusCode, 200);
@@ -870,6 +909,21 @@ test('POST /requestTest.html with non-https URL returns an error', async () => {
870
909
  assert.ok(res.body.includes('Invalid test request'));
871
910
  });
872
911
 
912
+ test('POST /requestTest.html with a private-address URL returns an error unless internal targets are allowed', async () => {
913
+ delete process.env.ALLOW_INTERNAL_TARGETS;
914
+ try {
915
+ const res = await formRequest('POST', '/requestTest.html', {
916
+ description: 'Internal Page',
917
+ url: 'https://192.168.1.1/page',
918
+ why: 'Because accessibility matters'
919
+ });
920
+ assert.equal(res.statusCode, 400);
921
+ assert.ok(res.body.includes('Invalid test request'));
922
+ } finally {
923
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
924
+ }
925
+ });
926
+
873
927
  test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
874
928
  const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
875
929
  why: ''
@@ -1192,6 +1246,62 @@ test('POST /worker/report with a claimed job but an unusable report returns an e
1192
1246
  await fs.unlink(failedJobPath).catch(() => {});
1193
1247
  });
1194
1248
 
1249
+ test('POST /worker/report with a report on a disallowed target rejects it and reclassifies the job', {timeout: 500}, async () => {
1250
+ delete process.env.ALLOW_INTERNAL_TARGETS;
1251
+ // Use a unique job ID that does not conflict with existing fixtures.
1252
+ const jobID = '990101T0002-ssr';
1253
+ const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
1254
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
1255
+ const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
1256
+ const jobFile = `${jobID}.json`;
1257
+ const claimedJobPath = path.join(claimedDir, jobFile);
1258
+ const failedJobPath = path.join(failedDir, jobFile);
1259
+ try {
1260
+ // Clean up any leftover files.
1261
+ await fs.unlink(reportPath).catch(() => {});
1262
+ await fs.unlink(failedJobPath).catch(() => {});
1263
+ // Create a claimed job assigned to Worker One.
1264
+ await fs.writeFile(claimedJobPath, JSON.stringify({
1265
+ id: jobID,
1266
+ target: {what: 'Test', url: 'https://example.com/test'},
1267
+ sources: {worker: 'Worker One'}
1268
+ }));
1269
+ const auth = Buffer.from('worker1:secret1').toString('base64');
1270
+ // A report that is otherwise usable, but whose test act was redirected (actualURL)
1271
+ // to a private address that this deployment does not allow as a testing target.
1272
+ const report = {
1273
+ id: jobID,
1274
+ target: {what: 'Test', url: 'https://example.com/test'},
1275
+ acts: [{
1276
+ type: 'test', which: 'axe', actualURL: 'https://192.168.1.1/test',
1277
+ result: {standardResult: {instances: []}}
1278
+ }],
1279
+ jobData: {endTime: '26-01-01T00:00'},
1280
+ catalog: {}
1281
+ };
1282
+ const res = await request('POST', '/worker/report', {report}, {
1283
+ authorization: `Basic ${auth}`
1284
+ });
1285
+ assert.equal(res.statusCode, 400);
1286
+ const body = jsonBody(res);
1287
+ assert.ok(body.error.message.includes('disallowed target'));
1288
+ // The report should not have been recorded.
1289
+ const reportExists = await fs.access(reportPath).then(() => true).catch(() => false);
1290
+ assert.equal(reportExists, false, 'Report on a disallowed target should not be saved');
1291
+ // Wait for the async rename to complete.
1292
+ await new Promise<void>(resolve => setTimeout(resolve, 100));
1293
+ // The job should have been reclassified as failed rather than left claimed.
1294
+ const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
1295
+ assert.ok(failedExists, 'Job should be moved to the failed directory');
1296
+ const claimedExists = await fs.access(claimedJobPath).then(() => true).catch(() => false);
1297
+ assert.equal(claimedExists, false, 'Job should be removed from the claimed directory');
1298
+ } finally {
1299
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
1300
+ // Clean up.
1301
+ await fs.unlink(failedJobPath).catch(() => {});
1302
+ }
1303
+ });
1304
+
1195
1305
  // TESTS: remaining error branches and web pages
1196
1306
 
1197
1307
  test('GET /style.css with a read error returns an error page', async () => {
@@ -1247,6 +1357,7 @@ const htmlPagePaths = [
1247
1357
  '/pruneReportsForm.html',
1248
1358
  '/rewindReportsForm.html',
1249
1359
  '/ai0BalanceForm.html',
1360
+ '/deleteNotesForm.html',
1250
1361
  '/metrics.html'
1251
1362
  ];
1252
1363
 
@@ -1424,6 +1535,32 @@ test('POST /rewindReportsForm.html with no selections shows the form and records
1424
1535
  assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
1425
1536
  });
1426
1537
 
1538
+ test('POST /deleteNotesForm.html deletes multiple selected notes across sources in one submission', async () => {
1539
+ await fs.writeFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, JSON.stringify([
1540
+ {timeStamp: '260101T0000', content: 'A web tutorial comment'}
1541
+ ]));
1542
+ await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify([
1543
+ {timeStamp: '260101T0001', content: 'A feature request'},
1544
+ {timeStamp: '260101T0001', content: 'A different feature request'}
1545
+ ]));
1546
+ // Reconstructing the POST body's query string uses querystring.stringify, which (unlike
1547
+ // the URLSearchParams object constructor) preserves multiple values for the same field
1548
+ // name as repeated key=value pairs rather than collapsing them into one comma-joined
1549
+ // value, so this exercises that multi-checkbox submission actually deletes every
1550
+ // selected note, not just the one whose value happens to survive the collapse.
1551
+ const res = await formRequest('POST', '/deleteNotesForm.html', [
1552
+ ['authCode', 'test-auth-code'],
1553
+ ['note', `tutorialWeb\t${encodeURIComponent('A web tutorial comment')}`],
1554
+ ['note', `featureRequest\t${encodeURIComponent('A feature request')}`]
1555
+ ]);
1556
+ assert.equal(res.statusCode, 200);
1557
+ const webComments = JSON.parse(await fs.readFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, 'utf8'));
1558
+ assert.equal(webComments.length, 0);
1559
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
1560
+ assert.equal(featureRequests.length, 1);
1561
+ assert.equal(featureRequests[0].content, 'A different feature request');
1562
+ });
1563
+
1427
1564
  test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
1428
1565
  const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
1429
1566
  const backup = await fs.readFile(reportPath, 'utf8');
package/index.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  getObject,
18
18
  getPOSTData,
19
19
  getReport,
20
+ isAllowedReport,
21
+ isAllowedTarget,
20
22
  isReportError,
21
23
  getReportPath,
22
24
  hiddenReportsPath,
@@ -42,7 +44,9 @@ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './
42
44
  import http, {type IncomingMessage, type ServerResponse} from 'node:http';
43
45
  import https from 'node:https';
44
46
  import path from 'node:path';
47
+ import querystring from 'node:querystring';
45
48
  import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
49
+ import {answer as deleteNotesForm} from './web/deleteNotesForm/index.ts';
46
50
  import {answer as enqueue} from './web/enqueue/index.ts';
47
51
  import {answer as enqueueForm} from './web/enqueueForm/index.ts';
48
52
  import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
@@ -97,6 +101,7 @@ type PageHandler = (...args: any[]) => Promise<AnswerData>;
97
101
 
98
102
  const answer: {
99
103
  ai0BalanceForm: PageHandler;
104
+ deleteNotesForm: PageHandler;
100
105
  enqueue: PageHandler;
101
106
  enqueueForm: PageHandler;
102
107
  expungeReportsForm: PageHandler;
@@ -126,6 +131,7 @@ const answer: {
126
131
  [key: string]: PageHandler | undefined;
127
132
  } = {
128
133
  ai0BalanceForm,
134
+ deleteNotesForm,
129
135
  enqueue,
130
136
  enqueueForm,
131
137
  expungeReportsForm,
@@ -207,6 +213,7 @@ export const routes = {
207
213
  '/api/*',
208
214
  '/mcp',
209
215
  '/ai0BalanceForm.html',
216
+ '/deleteNotesForm.html',
210
217
  '/expungeReportsForm.html',
211
218
  '/hideReportForm.html',
212
219
  '/metrics.html',
@@ -240,6 +247,7 @@ const managerPages = new Set([
240
247
  'unhideReportForm',
241
248
  'ai0BalanceForm',
242
249
  'renewWCAGForm',
250
+ 'deleteNotesForm',
243
251
  'metrics'
244
252
  ]);
245
253
  // The set of manager pages (a subset of managerPages) whose single answer() function both
@@ -255,6 +263,7 @@ const selfSubmittingManagerPages = new Set([
255
263
  'showHiddenReportsForm',
256
264
  'unhideReportForm',
257
265
  'ai0BalanceForm',
266
+ 'deleteNotesForm',
258
267
  'metrics'
259
268
  ]);
260
269
  // The subset of selfSubmittingManagerPages that must never be served on a direct GET
@@ -857,6 +866,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
857
866
  );
858
867
  }
859
868
  }
869
+ // Otherwise, if the request is a HEAD request: only the home page supports HEAD, since
870
+ // UptimeRobot's uptime check is the only known HEAD caller and it targets only that path
871
+ // (see "Health monitoring" in docs/SERVICE.md). Its response must match what GET / would
872
+ // send, minus the body, so that a monitor reading headers alone sees the real page state.
873
+ else if (method === 'HEAD') {
874
+ if (['/', '/index.html'].includes(pathname)) {
875
+ setHeaders('text/html', '/index.html', 'medium');
876
+ response.end();
877
+ }
878
+ else {
879
+ await serveError({message: `ERROR: Invalid HEAD request (${pathname})`}, response, true);
880
+ }
881
+ }
860
882
  // Otherwise, if the request is a POST request:
861
883
  else if (method === 'POST') {
862
884
  // If the path is not authorized for POST requests:
@@ -879,7 +901,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
879
901
  else if (pageName === 'requestTest.html') {
880
902
  const {description, url, why} = postData as {description?: string; url: string; why?: string};
881
903
  // If the request is valid:
882
- if (description && isURL(url) && why) {
904
+ if (description && isURL(url) && why && await isAllowedTarget(url)) {
883
905
  // If a report on the page is already available:
884
906
  if (await isReportAvailable(description, url)) {
885
907
  // Report the error.
@@ -1023,7 +1045,12 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1023
1045
  setHeaders('text/html', pathname, 'ultra');
1024
1046
  // Reconstruct a query string from the POST body, so the page's answer() function
1025
1047
  // can read its submitted parameters the same way it reads a GET query string.
1026
- const search = `?${new URLSearchParams(postData as Record<string, string>).toString()}`;
1048
+ // querystring.stringify (rather than the URLSearchParams object constructor) is
1049
+ // required here, because postData, from querystring.parse in getPOSTData, holds
1050
+ // repeated form fields (e.g. multiple checked checkboxes sharing a name) as an
1051
+ // array; the URLSearchParams constructor would join such an array into a single
1052
+ // comma-separated value instead of preserving it as repeated key=value pairs.
1053
+ const search = `?${querystring.stringify(postData as Record<string, string | string[]>)}`;
1027
1054
  // Get the answer data. The method is passed so the handler only processes a
1028
1055
  // submission for POST, never for GET, regardless of what its query string contains.
1029
1056
  const answerData = await answer[topic]!(pathTail, search, method);
@@ -1078,8 +1105,28 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1078
1105
  }
1079
1106
  // If the job was actually assigned to this worker:
1080
1107
  if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
1081
- // If the report is usable by Kilotest (as any report of an assigned job should be):
1082
- if (isUsableReport(reportObj)) {
1108
+ // If the report is on a target this deployment allows (a redirect during
1109
+ // navigation, possibly via DNS rebinding, can take a worker to a target that
1110
+ // was disallowed even though the originally submitted URL passed that check
1111
+ // at submission time):
1112
+ if (!(await isAllowedReport(reportObj))) {
1113
+ console.error(`ERROR: Report ${id} from worker ${workerName} is on a disallowed target`);
1114
+ // Alert a manager, since this indicates an attempted or accidental SSRF
1115
+ // rather than an ordinary usability problem with the report.
1116
+ await sendAlert(
1117
+ 'Kilotest: report on disallowed target rejected',
1118
+ `Job ${id} from worker ${workerName} resulted in a visit to a target this deployment does not allow (e.g. a private, loopback, or link-local address), possibly via a redirect. The report was discarded and the job was reclassified as failed.`
1119
+ );
1120
+ // Reclassify the job as failed, instead of recording the disallowed report or
1121
+ // leaving the job claimed indefinitely.
1122
+ await fs.rename(
1123
+ path.join(claimedPath(), `${id}.json`), path.join(failedPath(), `${id}.json`)
1124
+ );
1125
+ // Report the error.
1126
+ await serveError({message: `ERROR: Report ${id} is on a disallowed target`}, response, false);
1127
+ }
1128
+ // Otherwise, if the report is usable by Kilotest (as any report of an assigned job should be):
1129
+ else if (isUsableReport(reportObj)) {
1083
1130
  console.log(`Testaro report ${id} was received from worker ${workerName}`);
1084
1131
  // Add the public worker name to the report.
1085
1132
  report!.sources = {...report!.sources, worker: workerName};
@@ -1214,7 +1261,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1214
1261
  }
1215
1262
  }
1216
1263
  }
1217
- // Otherwise, i.e. if it is neither a GET nor a POST request:
1264
+ // Otherwise, i.e. if it is none of GET, HEAD, or POST:
1218
1265
  else {
1219
1266
  // Report its invalidity. (Caddy handles OPTIONS requests.)
1220
1267
  await serveError({message: 'ERROR: Invalid request method'}, response, true);
@@ -60,6 +60,7 @@ const htmlRoutes = [
60
60
  '/expungeReportsForm.html',
61
61
  '/pruneReportsForm.html',
62
62
  '/rewindReportsForm.html',
63
+ '/deleteNotesForm.html',
63
64
  '/renewWCAG.html',
64
65
  '/renewWCAGForm.html',
65
66
  '/enqueue.html',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "50.0.0",
3
+ "version": "52.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/robots.txt CHANGED
@@ -2,6 +2,7 @@
2
2
  User-agent: *
3
3
  Allow: /
4
4
  Disallow: /ai0BalanceForm.html
5
+ Disallow: /deleteNotesForm.html
5
6
  Disallow: /enqueueForm.html
6
7
  Disallow: /recAction.html
7
8
  Disallow: /reannotateForm.html
package/smokeTest.ts CHANGED
@@ -41,6 +41,7 @@ const concretePaths: Record<string, Record<string, string>> = {
41
41
  '/api/*': '/api/requestFeature',
42
42
  '/mcp': '/mcp',
43
43
  '/ai0BalanceForm.html': '/ai0BalanceForm.html',
44
+ '/deleteNotesForm.html': '/deleteNotesForm.html',
44
45
  '/expungeReportsForm.html': '/expungeReportsForm.html',
45
46
  '/hideReportForm.html': '/hideReportForm.html',
46
47
  '/metrics.html': '/metrics.html',
@@ -64,6 +65,7 @@ const postBodies: Record<string, object> = {
64
65
  '/api/requestFeature': {feature: 'smoke test'},
65
66
  '/mcp': {},
66
67
  '/ai0BalanceForm.html': {authCode: 'invalid'},
68
+ '/deleteNotesForm.html': {authCode: 'invalid'},
67
69
  '/expungeReportsForm.html': {authCode: 'invalid'},
68
70
  '/hideReportForm.html': {authCode: 'invalid'},
69
71
  '/metrics.html': {authCode: 'invalid'},
@@ -229,20 +231,23 @@ const isMCPErrorResponse = (result: {statusCode: number | undefined, body: strin
229
231
  console.log(`FAIL: POST ${mcpPath} initialize -> error: ${message}`);
230
232
  failures++;
231
233
  }
232
- // Send a browser-style GET to verify that invalid MCP requests get JSON-RPC errors end-to-end.
234
+ // Send a POST with an Accept header the MCP SDK itself rejects (missing text/event-stream)
235
+ // to verify that invalid MCP requests get JSON-RPC errors end-to-end. A browser-style GET
236
+ // is not used here because index.ts deliberately intercepts it before handleMCP, serving a
237
+ // human-readable explainer page instead of a JSON-RPC error.
233
238
  try {
234
- const probe = await sendMCPRequest('GET', 'text/html');
239
+ const probe = await sendMCPRequest('POST', 'application/json', mcpInitialize);
235
240
  if (isMCPErrorResponse(probe)) {
236
- console.log(`PASS: GET ${mcpPath} browser -> ${probe.statusCode} (JSON-RPC error)`);
241
+ console.log(`PASS: POST ${mcpPath} bad accept -> ${probe.statusCode} (JSON-RPC error)`);
237
242
  }
238
243
  else {
239
- console.log(`FAIL: GET ${mcpPath} browser -> ${probe.statusCode} (body: ${probe.body.slice(0, 120)})`);
244
+ console.log(`FAIL: POST ${mcpPath} bad accept -> ${probe.statusCode} (body: ${probe.body.slice(0, 120)})`);
240
245
  failures++;
241
246
  }
242
247
  }
243
248
  catch (error) {
244
249
  const message = error instanceof Error ? error.message : String(error);
245
- console.log(`FAIL: GET ${mcpPath} browser -> error: ${message}`);
250
+ console.log(`FAIL: POST ${mcpPath} bad accept -> error: ${message}`);
246
251
  failures++;
247
252
  }
248
253
  console.log(`\n=== ${failures === 0 ? 'All checks passed' : `${failures} failure(s)`} ===`);
package/test/setup.ts CHANGED
@@ -12,3 +12,8 @@
12
12
  for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
13
13
  process.env[key] = '';
14
14
  }
15
+ // Allow internal targets by default, so that tests submitting an ordinary
16
+ // https://example.com/... URL for testing do not depend on real DNS/network access
17
+ // to pass the resolution check that isAllowedTarget performs. A test of that check
18
+ // itself sets this env var back to unset (or false) around the specific call being tested.
19
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
package/util.test.ts CHANGED
@@ -7,8 +7,18 @@
7
7
 
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
+ import net from 'node:net';
10
11
  import path from 'node:path';
11
12
  import fs from 'node:fs/promises';
13
+
14
+ // Blank the alert configuration unconditionally, before util.ts (which processTestRequest
15
+ // calls sendAlert from) is ever imported below, so this file sends no real alert emails
16
+ // even when run directly (e.g. `npx tsx --test util.test.ts`) rather than via `npm test`,
17
+ // which normally guards against this via test/setup.ts.
18
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
19
+ process.env[key] = '';
20
+ }
21
+
12
22
  import {
13
23
  annotateReportObject,
14
24
  checkCommentDuplicate,
@@ -42,6 +52,8 @@ import {
42
52
  getWeightName,
43
53
  hiddenReportsPath,
44
54
  htmlSafe,
55
+ isAllowedReport,
56
+ isAllowedTarget,
45
57
  isJobID,
46
58
  isReportAvailable,
47
59
  isTimeStamp,
@@ -116,26 +128,18 @@ test('checkCommentDuplicate accepts a comment with no existing comments', () =>
116
128
  assert.deepEqual(result, {status: 'ok'});
117
129
  });
118
130
 
119
- test('checkCommentDuplicate rejects a comment repeating one submitted within the last 1000 seconds', () => {
131
+ test('checkCommentDuplicate rejects a comment identical to one already stored, regardless of when', () => {
120
132
  const content = 'a'.repeat(20);
121
- const comments = [{timeStamp: getNowStamp(), content}];
133
+ const comments = [{content}];
122
134
  const result = checkCommentDuplicate(comments, content);
123
135
  assert.deepEqual(result, {
124
136
  status: 'error',
125
- message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
137
+ message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
126
138
  });
127
139
  });
128
140
 
129
- test('checkCommentDuplicate accepts a comment repeating one submitted more than 1000 seconds ago', () => {
130
- const content = 'a'.repeat(20);
131
- const oldTimeStamp = getTimeStamp(new Date(Date.now() - 2000000));
132
- const comments = [{timeStamp: oldTimeStamp, content}];
133
- const result = checkCommentDuplicate(comments, content);
134
- assert.deepEqual(result, {status: 'ok'});
135
- });
136
-
137
- test('checkCommentDuplicate accepts a comment that differs from a recent one', () => {
138
- const comments = [{timeStamp: getNowStamp(), content: 'a'.repeat(20)}];
141
+ test('checkCommentDuplicate accepts a comment that differs from a stored one', () => {
142
+ const comments = [{content: 'a'.repeat(20)}];
139
143
  const result = checkCommentDuplicate(comments, 'b'.repeat(20));
140
144
  assert.deepEqual(result, {status: 'ok'});
141
145
  });
@@ -365,6 +369,160 @@ test('isURL returns false for an invalid URL', () => {
365
369
  assert.equal(isURL('not-a-url'), false);
366
370
  });
367
371
 
372
+ // A fake resolver, so isAllowedTarget tests need no real DNS/network access.
373
+ const fakeLookup = (address: string) => async () => [{address, family: net.isIP(address)}];
374
+
375
+ // isAllowedTarget and isAllowedReport branch on ALLOW_INTERNAL_TARGETS, which other test
376
+ // files (sharing this process) set to "true" for their own unrelated tests, so every test
377
+ // below sets the env var itself, rather than relying on it being unset by default, and
378
+ // restores whatever value preceded it.
379
+ let savedAllowInternalTargets: string | undefined;
380
+
381
+ before(() => {
382
+ savedAllowInternalTargets = process.env.ALLOW_INTERNAL_TARGETS;
383
+ });
384
+
385
+ after(() => {
386
+ if (savedAllowInternalTargets === undefined) {
387
+ delete process.env.ALLOW_INTERNAL_TARGETS;
388
+ }
389
+ else {
390
+ process.env.ALLOW_INTERNAL_TARGETS = savedAllowInternalTargets;
391
+ }
392
+ });
393
+
394
+ test('isAllowedTarget returns true for a URL resolving to a public address', async () => {
395
+ delete process.env.ALLOW_INTERNAL_TARGETS;
396
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('93.184.216.34')), true);
397
+ });
398
+
399
+ test('isAllowedTarget returns false for a URL resolving to a loopback address', async () => {
400
+ delete process.env.ALLOW_INTERNAL_TARGETS;
401
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('127.0.0.1')), false);
402
+ });
403
+
404
+ test('isAllowedTarget returns false for a URL resolving to a private address', async () => {
405
+ delete process.env.ALLOW_INTERNAL_TARGETS;
406
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('10.0.0.5')), false);
407
+ });
408
+
409
+ test('isAllowedTarget returns false for a URL resolving to a 172.16/12 private address', async () => {
410
+ delete process.env.ALLOW_INTERNAL_TARGETS;
411
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('172.20.0.5')), false);
412
+ });
413
+
414
+ test('isAllowedTarget returns false for a URL resolving to a carrier-grade NAT address', async () => {
415
+ delete process.env.ALLOW_INTERNAL_TARGETS;
416
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('100.70.0.5')), false);
417
+ });
418
+
419
+ test('isAllowedTarget returns false for a URL resolving to a link-local/cloud-metadata address', async () => {
420
+ delete process.env.ALLOW_INTERNAL_TARGETS;
421
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('169.254.169.254')), false);
422
+ });
423
+
424
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 loopback address', async () => {
425
+ delete process.env.ALLOW_INTERNAL_TARGETS;
426
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::1')), false);
427
+ });
428
+
429
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 unspecified address', async () => {
430
+ delete process.env.ALLOW_INTERNAL_TARGETS;
431
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::')), false);
432
+ });
433
+
434
+ test('isAllowedTarget returns false for a URL resolving to an IPv4-mapped private IPv6 address', async () => {
435
+ delete process.env.ALLOW_INTERNAL_TARGETS;
436
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::ffff:10.0.0.5')), false);
437
+ });
438
+
439
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 unique-local address', async () => {
440
+ delete process.env.ALLOW_INTERNAL_TARGETS;
441
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('fd12:3456:789a:1::1')), false);
442
+ });
443
+
444
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 link-local address', async () => {
445
+ delete process.env.ALLOW_INTERNAL_TARGETS;
446
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('fe80::1')), false);
447
+ });
448
+
449
+ test('isAllowedTarget returns true for a URL resolving to a public IPv6 address', async () => {
450
+ delete process.env.ALLOW_INTERNAL_TARGETS;
451
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('2606:2800:220:1:248:1893:25c8:1946')), true);
452
+ });
453
+
454
+ test('isAllowedTarget returns false for a URL resolving to an address family net.isIP does not recognize', async () => {
455
+ delete process.env.ALLOW_INTERNAL_TARGETS;
456
+ assert.equal(await isAllowedTarget('https://example.com/page', async () => [{address: 'not-an-ip', family: 0}]), false);
457
+ });
458
+
459
+ test('isAllowedTarget returns false when the DNS lookup rejects', async () => {
460
+ delete process.env.ALLOW_INTERNAL_TARGETS;
461
+ const lookup = async () => { throw new Error('lookup failed'); };
462
+ assert.equal(await isAllowedTarget('https://nonexistent.invalid/page', lookup), false);
463
+ });
464
+
465
+ test('isAllowedTarget returns false for a URL whose literal host is a private IP', async () => {
466
+ delete process.env.ALLOW_INTERNAL_TARGETS;
467
+ assert.equal(await isAllowedTarget('https://192.168.1.1/page'), false);
468
+ });
469
+
470
+ test('isAllowedTarget returns true for a URL whose literal host is a public IP', async () => {
471
+ delete process.env.ALLOW_INTERNAL_TARGETS;
472
+ assert.equal(await isAllowedTarget('https://93.184.216.34/page'), true);
473
+ });
474
+
475
+ test('isAllowedTarget returns false for a syntactically invalid URL', async () => {
476
+ delete process.env.ALLOW_INTERNAL_TARGETS;
477
+ assert.equal(await isAllowedTarget('not-a-url'), false);
478
+ });
479
+
480
+ test('isAllowedTarget returns true for a private-address target when ALLOW_INTERNAL_TARGETS is true', async () => {
481
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
482
+ assert.equal(await isAllowedTarget('https://192.168.1.1/page'), true);
483
+ });
484
+
485
+ test('isAllowedReport returns true when every act has a public actualURL', async () => {
486
+ delete process.env.ALLOW_INTERNAL_TARGETS;
487
+ const report = {
488
+ target: {what: 'Example', url: 'https://example.com/page'},
489
+ acts: [
490
+ {type: 'test', actualURL: 'https://example.com/page'},
491
+ {type: 'test', actualURL: 'https://example.com/page2'}
492
+ ]
493
+ };
494
+ assert.equal(await isAllowedReport(report, fakeLookup('93.184.216.34')), true);
495
+ });
496
+
497
+ test('isAllowedReport returns false when any act has a disallowed actualURL', async () => {
498
+ delete process.env.ALLOW_INTERNAL_TARGETS;
499
+ const report = {
500
+ target: {what: 'Example', url: 'https://example.com/page'},
501
+ acts: [
502
+ {type: 'test', actualURL: 'https://example.com/page'},
503
+ {type: 'test', actualURL: 'https://internal.example.net/page'}
504
+ ]
505
+ };
506
+ let callCount = 0;
507
+ const lookup = async (hostname: string) => {
508
+ callCount++;
509
+ return hostname === 'internal.example.net'
510
+ ? [{address: '10.0.0.5', family: 4}]
511
+ : [{address: '93.184.216.34', family: 4}];
512
+ };
513
+ assert.equal(await isAllowedReport(report, lookup), false);
514
+ assert.ok(callCount > 0);
515
+ });
516
+
517
+ test('isAllowedReport falls back to the report target URL when an act has no actualURL', async () => {
518
+ delete process.env.ALLOW_INTERNAL_TARGETS;
519
+ const report = {
520
+ target: {what: 'Example', url: 'https://example.com/page'},
521
+ acts: [{type: 'test'}]
522
+ };
523
+ assert.equal(await isAllowedReport(report, fakeLookup('93.184.216.34')), true);
524
+ });
525
+
368
526
  test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
369
527
  const saved = process.env.AUTH_CODE;
370
528
  process.env.AUTH_CODE = 'secret-code';