@jrpool/kilotest 49.0.0 → 51.1.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.
@@ -0,0 +1,167 @@
1
+ /*
2
+ index.ts
3
+ Serves a form for deleting web tutorial comments, AI tutorial comments, and MCP
4
+ feature requests, the three kinds of notes that Kilotest records to flat JSON files
5
+ rather than presenting through any other manager page.
6
+ */
7
+
8
+ // IMPORTS
9
+
10
+ import {errorMessage, getDateTimeString, getJSON, getPlainText, isValidAuthCode, populateTemplate} from '../../util.ts';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ // TYPES
15
+
16
+ // A note (comment or feature request) as stored in one of the note files.
17
+ type Note = {
18
+ timeStamp: string;
19
+ content: string;
20
+ };
21
+
22
+ // The source of a kind of note, identifying its file and how it is labeled.
23
+ type NoteSource = {
24
+ name: string;
25
+ label: string;
26
+ getPath: () => string;
27
+ };
28
+
29
+ // CONSTANTS
30
+
31
+ // The note sources, in the order they are listed on the form. Each source's path is
32
+ // read afresh on every call, via its own environment-variable override, exactly as
33
+ // web/tutorialWeb/index.ts and web/tutorialAI/index.ts already do for their own
34
+ // comment files, so that tests can point any of them at a fixture path.
35
+ const noteSources: NoteSource[] = [
36
+ {
37
+ name: 'tutorialWeb',
38
+ label: 'Web tutorial comment',
39
+ getPath: () => process.env.TUTORIAL_WEB_COMMENTS_PATH ||
40
+ path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json')
41
+ },
42
+ {
43
+ name: 'tutorialAI',
44
+ label: 'AI tutorial comment',
45
+ getPath: () => process.env.TUTORIAL_AI_COMMENTS_PATH ||
46
+ path.join(import.meta.dirname, '../../db/comments/tutorialAI.json')
47
+ },
48
+ {
49
+ name: 'featureRequest',
50
+ label: 'MCP feature request',
51
+ getPath: () => process.env.FEATURE_REQUESTS_PATH ||
52
+ path.join(import.meta.dirname, '../../db/featureRequests.json')
53
+ }
54
+ ];
55
+
56
+ // FUNCTIONS (helpers)
57
+
58
+ // Returns the notes in a source's file, or an empty array if it does not exist.
59
+ const getNotes = async (source: NoteSource): Promise<Note[]> => {
60
+ try {
61
+ const existing = await fs.readFile(source.getPath(), 'utf8');
62
+ return JSON.parse(existing);
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ };
68
+
69
+ // FUNCTIONS
70
+
71
+ // Returns a form for deleting selected notes, or, on a POST request with a valid
72
+ // authorization code, deletes the selected notes and returns the form again. A GET
73
+ // request never processes a submission, regardless of its query string; only a POST
74
+ // request (the form's own submission) does.
75
+ export const answer = async (
76
+ _: any, search: string, method: string
77
+ ): Promise<{status: string; message?: string; answerPage?: string}> => {
78
+ const searchParams = new URLSearchParams(search);
79
+ const authCode = searchParams?.get('authCode');
80
+ // Each selected checkbox's value is `${sourceName}\t${encodeURIComponent(content)}`.
81
+ // Content, rather than time stamp, identifies a note, because no two notes in the same
82
+ // source ever share content: checkCommentDuplicate rejects a submission identical to
83
+ // one already on file. Content is percent-encoded, and split out at the first tab
84
+ // only, because comment and feature-request content is not stripped of tab characters
85
+ // (see the sanitize functions in web/tutorialWeb/index.ts and web/tutorialAI/index.ts),
86
+ // so an unencoded tab within the content itself could otherwise be mistaken for the
87
+ // delimiter between the source name and the content.
88
+ const selections = searchParams?.getAll('note');
89
+ // If the form has been submitted and any notes are to be deleted:
90
+ if (method === 'POST' && selections?.length) {
91
+ // If the authorization code is valid:
92
+ if (isValidAuthCode(authCode)) {
93
+ try {
94
+ // Group the selected content by source.
95
+ const contentBySource = new Map<string, Set<string>>();
96
+ for (const selection of selections) {
97
+ const tabIndex = selection.indexOf('\t');
98
+ const sourceName = selection.slice(0, tabIndex);
99
+ const content = decodeURIComponent(selection.slice(tabIndex + 1));
100
+ if (!contentBySource.has(sourceName)) {
101
+ contentBySource.set(sourceName, new Set());
102
+ }
103
+ contentBySource.get(sourceName)!.add(content);
104
+ }
105
+ // For each source with any selected notes:
106
+ for (const source of noteSources) {
107
+ const contentSet = contentBySource.get(source.name);
108
+ if (!contentSet) {
109
+ continue;
110
+ }
111
+ // Remove the selected notes and save the rest.
112
+ const notes = await getNotes(source);
113
+ const remaining = notes.filter(note => !contentSet.has(note.content));
114
+ await fs.mkdir(path.dirname(source.getPath()), {recursive: true});
115
+ await fs.writeFile(source.getPath(), getJSON(remaining));
116
+ }
117
+ }
118
+ // If this failed:
119
+ catch (error: unknown) {
120
+ // Return why.
121
+ return {
122
+ status: 'error',
123
+ message: `Deleting notes failed (${errorMessage(error)})`
124
+ };
125
+ }
126
+ }
127
+ // Otherwise, i.e. if the authorization code is invalid:
128
+ else {
129
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
130
+ // authorization code specifically (as opposed to some other part of the request) is
131
+ // what was wrong.
132
+ return {
133
+ status: 'error',
134
+ message: 'Invalid request'
135
+ };
136
+ }
137
+ }
138
+ const lines: string[] = [];
139
+ const margin = ' '.repeat(12);
140
+ let anyNotes = false;
141
+ // For each source, in display order:
142
+ for (const source of noteSources) {
143
+ const notes = await getNotes(source);
144
+ // For each of its notes, oldest first:
145
+ for (const note of notes) {
146
+ anyNotes = true;
147
+ const value = `${source.name}\t${encodeURIComponent(note.content)}`;
148
+ const when = getDateTimeString(note.timeStamp);
149
+ lines.push(
150
+ `${margin}<p><input type="checkbox" name="note" value="${value}"> ` +
151
+ `<strong>${source.label}</strong> (${when}): ${getPlainText(note.content)}</p>`
152
+ );
153
+ }
154
+ }
155
+ const query: Record<string, string> = {
156
+ notes: lines.join('\n'),
157
+ intro: anyNotes ?
158
+ 'Choose the comments and feature requests to delete.' :
159
+ 'There are no comments or feature requests to delete.',
160
+ disabled: anyNotes ? '' : ' disabled'
161
+ };
162
+ // Return the populated page.
163
+ return {
164
+ status: 'ok',
165
+ answerPage: await populateTemplate(import.meta.dirname, query)
166
+ };
167
+ };
@@ -6,54 +6,45 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {
9
- deleteTestRequests, getJSON, getNowStamp, getRandomString, isURL, jobsPath, populateTemplate
9
+ deleteTestRequests, getJSON, getNowStamp, getRandomString, jobsPath, populateTemplate
10
10
  } from '../../util.ts';
11
11
  import fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
 
14
14
  // FUNCTIONS
15
15
 
16
- // Implements a test request approval and returns a revised request page.
17
- export const answer = async (url: string, description: string, authCode: string) => {
18
- // If the arguments are valid:
19
- if (isURL(url) && description && authCode === process.env.AUTH_CODE) {
20
- // Get the job template.
21
- const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
22
- const job = JSON.parse(jobTemplateJSON);
23
- const nowStamp = getNowStamp();
24
- // Populate the template with job properties.
25
- const jobIDSuffix = getRandomString(3);
26
- const jobName = `${nowStamp}-${jobIDSuffix}`;
27
- job.id = jobName;
28
- job.creationTimeStamp = nowStamp;
29
- job.executionTimeStamp = nowStamp;
30
- job.target.what = description;
31
- job.target.url = url;
32
- const query: Record<string, string> = {
33
- target: description,
34
- jobName
35
- };
36
- // Save the job in the queue.
37
- await fs.writeFile(
38
- path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
39
- );
40
- console.log(`Retest queued for ${description} as job ${jobName}`);
41
- // Delete the test requests for the target.
42
- await deleteTestRequests(url);
43
- // Get the populated answer template.
44
- const answerPage = await populateTemplate(import.meta.dirname, query);
45
- // Return the populated page.
46
- return {
47
- status: 'ok',
48
- answerPage
49
- };
50
- }
51
- // Otherwise, i.e. if the authorization code is invalid, return an error page. The
52
- // message is deliberately vague so as not to confirm to an attacker that the
53
- // authorization code specifically (as opposed to some other part of the request) is
54
- // what was wrong.
16
+ // Implements a test request approval and returns a revised request page. The caller
17
+ // (index.ts, this function's only caller) has already validated the URL and
18
+ // authorization code before invoking this function.
19
+ export const answer = async (url: string, description: string) => {
20
+ // Get the job template.
21
+ const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
22
+ const job = JSON.parse(jobTemplateJSON);
23
+ const nowStamp = getNowStamp();
24
+ // Populate the template with job properties.
25
+ const jobIDSuffix = getRandomString(3);
26
+ const jobName = `${nowStamp}-${jobIDSuffix}`;
27
+ job.id = jobName;
28
+ job.creationTimeStamp = nowStamp;
29
+ job.executionTimeStamp = nowStamp;
30
+ job.target.what = description;
31
+ job.target.url = url;
32
+ const query: Record<string, string> = {
33
+ target: description,
34
+ jobName
35
+ };
36
+ // Save the job in the queue.
37
+ await fs.writeFile(
38
+ path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
39
+ );
40
+ console.log(`Retest queued for ${description} as job ${jobName}`);
41
+ // Delete the test requests for the target.
42
+ await deleteTestRequests(url);
43
+ // Get the populated answer template.
44
+ const answerPage = await populateTemplate(import.meta.dirname, query);
45
+ // Return the populated page.
55
46
  return {
56
- status: 'error',
57
- message: 'Invalid request'
47
+ status: 'ok',
48
+ answerPage
58
49
  };
59
50
  };
@@ -5,7 +5,9 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {getReportExtracts, hiddenReportsPath, populateTemplate, reportsPath} from '../../util.ts';
8
+ import {
9
+ getReportExtracts, hiddenReportsPath, isValidAuthCode, populateTemplate, reportsPath
10
+ } from '../../util.ts';
9
11
  import fs from 'node:fs/promises';
10
12
  import path from 'node:path';
11
13
 
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
20
22
  // If the form has been submitted and a report is to be hidden:
21
23
  if (method === 'POST' && jobName) {
22
24
  // If the authorization code is valid:
23
- if (authCode === process.env.AUTH_CODE) {
25
+ if (isValidAuthCode(authCode)) {
24
26
  const fileName = `${jobName}.json`;
25
27
  try {
26
28
  // Move the specified report to the directory of hidden reports.
@@ -16,6 +16,7 @@
16
16
  <p>Kilotest managers can:</p>
17
17
  <ul class="nav">
18
18
  <li><a href="/enqueueForm.html">Approve or reject a test recommendation</a></li>
19
+ <li><a href="/deleteNotesForm.html">Delete a comment or feature request</a></li>
19
20
  <li><a href="/reannotateForm.html">
20
21
  Reannotate reports to incorporate newly classified and reclassified rules
21
22
  </a></li>
@@ -7,7 +7,8 @@
7
7
  // IMPORTS
8
8
 
9
9
  import {
10
- clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, metricsExclusionCookieName, populateTemplate
10
+ clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, isValidAuthCode,
11
+ metricsExclusionCookieName, populateTemplate
11
12
  } from '../../util.ts';
12
13
 
13
14
  // FUNCTIONS
@@ -64,7 +65,7 @@ export const answer = async (_: any, search: string, method: string) => {
64
65
  // If the form has been submitted:
65
66
  if (method === 'POST') {
66
67
  // If the authorization code is invalid:
67
- if (authCode !== process.env.AUTH_CODE) {
68
+ if (!isValidAuthCode(authCode)) {
68
69
  // Report the error, deliberately vague so as not to confirm to an attacker that the
69
70
  // authorization code specifically (as opposed to some other part of the request) is
70
71
  // what was wrong.
@@ -11,6 +11,7 @@ import {
11
11
  getReport,
12
12
  getReportPath,
13
13
  isReportError,
14
+ isValidAuthCode,
14
15
  readdirOrCreate,
15
16
  reportsPath
16
17
  } from '../../util.ts';
@@ -22,7 +23,7 @@ import path from 'node:path';
22
23
  // Implements a reannotation order and returns an acknowledgement page.
23
24
  export const answer = async (authCode: string) => {
24
25
  // If the authorization code is valid:
25
- if (authCode === process.env.AUTH_CODE) {
26
+ if (isValidAuthCode(authCode)) {
26
27
  // Get the names of the stored report files.
27
28
  const reportFileNames = (await readdirOrCreate(reportsPath(), 'Reports directory'))
28
29
  .filter(fileName => fileName.endsWith('.json'));
@@ -7,14 +7,14 @@
7
7
 
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import {getJSON} from '../../util.ts';
10
+ import {getJSON, isValidAuthCode} from '../../util.ts';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
14
  // Renews the WCAG map and serves an acknowledgment.
15
15
  export const answer = async (authCode: string) => {
16
16
  // If the authorization code is valid:
17
- if (authCode === process.env.AUTH_CODE) {
17
+ if (isValidAuthCode(authCode)) {
18
18
  // Get the map source response.
19
19
  const mapSourceResponse = await fetch('https://www.w3.org/WAI/WCAG22/Understanding/');
20
20
  const mapSourceStatus = mapSourceResponse.status;
@@ -6,7 +6,8 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {
9
- errorMessage, getReportData, objectSort, populateTemplate, readdirOrCreate, reportsPath
9
+ errorMessage, getReportData, isValidAuthCode, objectSort, populateTemplate, readdirOrCreate,
10
+ reportsPath
10
11
  } from '../util.ts';
11
12
  import fs from 'node:fs/promises';
12
13
  import path from 'node:path';
@@ -48,7 +49,7 @@ export const reportDeletionForm = async (
48
49
  // If the form has been submitted and any reports are to be deleted:
49
50
  if (method === 'POST' && jobNames?.length) {
50
51
  // If the authorization code is valid:
51
- if (authCode === process.env.AUTH_CODE) {
52
+ if (isValidAuthCode(authCode)) {
52
53
  try {
53
54
  // For each report to be deleted:
54
55
  for (const jobName of jobNames) {
@@ -7,7 +7,7 @@
7
7
 
8
8
  // IMPORTS
9
9
 
10
- import {populateTemplate} from '../../util.ts';
10
+ import {isValidAuthCode, populateTemplate} from '../../util.ts';
11
11
  import {answer as unhideReportForm} from '../unhideReportForm/index.ts';
12
12
 
13
13
  // FUNCTIONS
@@ -22,7 +22,7 @@ export const answer = async (pathTail: any, search: string, method: string) => {
22
22
  // If the form has been submitted:
23
23
  if (method === 'POST') {
24
24
  // If the authorization code is invalid:
25
- if (authCode !== process.env.AUTH_CODE) {
25
+ if (!isValidAuthCode(authCode)) {
26
26
  // Report the error, deliberately vague so as not to confirm to an attacker that the
27
27
  // authorization code specifically (as opposed to some other part of the request) is
28
28
  // what was wrong.
@@ -6,7 +6,7 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {sendAlert} from '../../alerts.ts';
9
- import {checkCommentLength, getJSON} from '../../util.ts';
9
+ import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
10
10
  import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
 
@@ -56,18 +56,13 @@ export const handleComment = async (content: unknown) => {
56
56
  return {status: 'error', message: 'Comment is empty after sanitization'};
57
57
  }
58
58
  // Check for duplicate submissions within the last 1000 seconds.
59
- const duplicates = comments
60
- .filter(comment => new Date(comment.dateTime).getTime() > Date.now() - 1000000)
61
- .filter(comment => comment.content === sanitized);
62
- if (duplicates.length) {
63
- return {
64
- status: 'error',
65
- message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
66
- };
59
+ const duplicateCheck = checkCommentDuplicate(comments, sanitized);
60
+ if (duplicateCheck.status === 'error') {
61
+ return duplicateCheck;
67
62
  }
68
- // Add the comment to the existing ones, using dateTime format.
63
+ // Add the comment to the existing ones.
69
64
  comments.push({
70
- dateTime: new Date().toISOString(),
65
+ timeStamp: getNowStamp(),
71
66
  content: sanitized
72
67
  });
73
68
  // Ensure the comments directory exists.
@@ -14,6 +14,12 @@ import path from 'node:path';
14
14
 
15
15
  const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
16
16
  process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testDir, 'tutorialAI.json');
17
+ // Blank the alert configuration unconditionally, before index.ts (whose handleComment
18
+ // calls sendAlert for a new comment) is ever imported below, so this file sends no real
19
+ // alert emails even when run directly rather than via `npm test`.
20
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
21
+ process.env[key] = '';
22
+ }
17
23
 
18
24
  import {answer, handleComment} from './index.ts';
19
25
 
@@ -71,7 +77,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
71
77
  assert.equal(result.message, 'Your comment was longer than 1000 characters');
72
78
  });
73
79
 
74
- test('handleComment returns an error for a comment repeating one submitted within the last 1000 seconds', {timeout: 500}, async () => {
80
+ test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
75
81
  await fs.writeFile(getCommentsPath(), '[]\n');
76
82
  const content = 'This is a duplicate comment.';
77
83
  const firstResult = await handleComment(content);
@@ -80,7 +86,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
80
86
  assert.equal(secondResult.status, 'error');
81
87
  assert.equal(
82
88
  secondResult.message,
83
- 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
89
+ 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
84
90
  );
85
91
  });
86
92
 
@@ -91,7 +97,7 @@ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, a
91
97
  const comments = JSON.parse(await fs.readFile(getCommentsPath(), 'utf8'));
92
98
  assert.equal(comments.length, 1);
93
99
  assert.equal(comments[0].content, 'This is a test comment.');
94
- assert.ok(comments[0].dateTime);
100
+ assert.ok(comments[0].timeStamp);
95
101
  });
96
102
 
97
103
  test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
@@ -14,6 +14,12 @@ import path from 'node:path';
14
14
 
15
15
  const testDir = path.join(import.meta.dirname, '../../test/fixtures/comments');
16
16
  process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testDir, 'tutorialWeb.json');
17
+ // Blank the alert configuration unconditionally, before index.ts (whose handleComment
18
+ // calls sendAlert for a new comment) is ever imported below, so this file sends no real
19
+ // alert emails even when run directly rather than via `npm test`.
20
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
21
+ process.env[key] = '';
22
+ }
17
23
 
18
24
  import {answer, handleComment} from './index.ts';
19
25
 
@@ -73,7 +79,7 @@ test('handleComment returns an error for a comment longer than 1000 characters',
73
79
  assert.equal(result.message, 'Your comment was longer than 1000 characters');
74
80
  });
75
81
 
76
- test('handleComment returns an error for a comment repeating one submitted within the last 1000 seconds', {timeout: 500}, async () => {
82
+ test('handleComment returns an error for a comment identical to one already submitted', {timeout: 500}, async () => {
77
83
  await fs.writeFile(getCommentsPath(), '[]\n');
78
84
  const content = 'This is a duplicate comment.';
79
85
  const firstResult = await handleComment(content);
@@ -82,7 +88,7 @@ test('handleComment returns an error for a comment repeating one submitted withi
82
88
  assert.equal(secondResult.status, 'error');
83
89
  assert.equal(
84
90
  secondResult.message,
85
- 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
91
+ 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
86
92
  );
87
93
  });
88
94
 
@@ -5,7 +5,9 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {hiddenReportsPath, populateTemplate, readdirOrCreate, reportsPath} from '../../util.ts';
8
+ import {
9
+ hiddenReportsPath, isValidAuthCode, populateTemplate, readdirOrCreate, reportsPath
10
+ } from '../../util.ts';
9
11
  import fs from 'node:fs/promises';
10
12
  import path from 'node:path';
11
13
 
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
20
22
  // If the form has been submitted and a report is to be unhidden:
21
23
  if (method === 'POST' && jobName) {
22
24
  // If the authorization code is valid:
23
- if (authCode === process.env.AUTH_CODE) {
25
+ if (isValidAuthCode(authCode)) {
24
26
  const fileName = `${jobName}.json`;
25
27
  try {
26
28
  // Move the report to the reports directory.
@@ -1 +0,0 @@
1
- {"sessionId":"f44e6b44-375e-4007-879a-19e4e5ed34a0","pid":90042,"procStart":"Sun Sep 20 03:19:03 2026","acquiredAt":1789874948676}