@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/util.ts CHANGED
@@ -12,7 +12,9 @@ import {sendAlert} from './alerts.ts';
12
12
  import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues';
13
13
  import type {Act, Catalog, Report, StandardInstance} from 'testaro';
14
14
  import crypto from 'node:crypto';
15
+ import dns from 'node:dns/promises';
15
16
  import fs from 'node:fs/promises';
17
+ import net from 'node:net';
16
18
  import path from 'node:path';
17
19
  import querystring from 'node:querystring';
18
20
  /* c8 ignore stop */
@@ -207,27 +209,22 @@ export const checkCommentLength = (
207
209
  }
208
210
  return {status: 'ok'};
209
211
  };
210
- // Returns whether a comment repeats, verbatim, one of the given comments submitted
211
- // within the last 1000 seconds. Comment time stamps use Kilotest's own timeStamp
212
- // format (see getTimeStamp), not an ISO date-time string, so this integrates with
213
- // the same comments.json shape that web/tutorial/index.ts already uses. The content
214
- // passed in should be the content as it will be stored (e.g. after sanitization),
215
- // so that it is compared on the same basis as the stored comments.
212
+ // Returns whether a comment repeats, verbatim, one of the given comments, regardless
213
+ // of when the earlier one was submitted: a legitimate need to resubmit exactly the
214
+ // same content never arises (the near-term case is a browser "Back" resubmission),
215
+ // so storing a second copy would only ever be redundant. The content passed in
216
+ // should be the content as it will be stored (e.g. after sanitization), so that it
217
+ // is compared on the same basis as the stored comments. Content, not any time
218
+ // stamp, is what this checks and what identifies a comment as unique.
216
219
  export const checkCommentDuplicate = (
217
- comments: {timeStamp: string; content: string}[], content: string
220
+ comments: {content: string}[], content: string
218
221
  ): {status: 'ok'} | {status: 'error'; message: string} => {
219
- // Get the comments submitted within the last 1000 seconds with the same content.
220
- // Comment time stamps are always valid, because this codebase is the sole writer
221
- // of comments.json.
222
- const duplicates = comments
223
- .filter(comment => getDateTime(comment.timeStamp)!.getTime() > Date.now() - 1000000)
224
- .filter(comment => comment.content === content);
225
- // If the comment duplicates a recent one:
226
- if (duplicates.length) {
222
+ // If the comment duplicates one already stored:
223
+ if (comments.some(comment => comment.content === content)) {
227
224
  // Return this.
228
225
  return {
229
226
  status: 'error',
230
- message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
227
+ message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
231
228
  };
232
229
  }
233
230
  // Otherwise, the comment is not a duplicate.
@@ -554,6 +551,95 @@ export const isURL = (string: string): boolean => {
554
551
  return false;
555
552
  }
556
553
  };
554
+ // Returns whether an IPv4 address (in dotted-decimal form) is in a private,
555
+ // loopback, link-local, or otherwise non-public range, including the
556
+ // 169.254.169.254 cloud-metadata address (which falls under link-local).
557
+ const isNonPublicIPv4 = (address: string): boolean => {
558
+ const octets = address.split('.').map(Number);
559
+ const [a, b = 0] = octets;
560
+ return (
561
+ a === 127 || // loopback
562
+ a === 10 || // private
563
+ a === 0 || // "this network"
564
+ (a === 172 && b >= 16 && b <= 31) || // private
565
+ (a === 192 && b === 168) || // private
566
+ (a === 169 && b === 254) || // link-local, incl. cloud metadata
567
+ (a === 100 && b >= 64 && b <= 127) // carrier-grade NAT
568
+ );
569
+ };
570
+ // Returns whether an IPv6 address is in a loopback, unique-local, or
571
+ // link-local range.
572
+ const isNonPublicIPv6 = (address: string): boolean => {
573
+ const normalized = address.toLowerCase();
574
+ return (
575
+ normalized === '::1' // loopback
576
+ || normalized === '::' // unspecified
577
+ || /^::ffff:/.test(normalized) && isNonPublicIPv4(normalized.replace(/^::ffff:/, '')) // IPv4-mapped
578
+ || /^f[cd][0-9a-f]{2}:/.test(normalized) // unique local (fc00::/7)
579
+ || /^fe[89ab][0-9a-f]:/.test(normalized) // link-local (fe80::/10)
580
+ );
581
+ };
582
+ // Returns whether an IP address (v4 or v6) is outside the public address
583
+ // space, i.e. is a loopback, private, link-local, or other reserved address
584
+ // that a public deployment should never intend to test.
585
+ const isNonPublicIP = (address: string): boolean => {
586
+ const version = net.isIP(address);
587
+ if (version === 4) {
588
+ return isNonPublicIPv4(address);
589
+ }
590
+ if (version === 6) {
591
+ return isNonPublicIPv6(address);
592
+ }
593
+ // Not a recognizable IP address, so treat it as non-public, i.e. as unsafe.
594
+ return true;
595
+ };
596
+ // Returns whether ALLOW_INTERNAL_TARGETS opts this deployment in to testing
597
+ // pages at private, loopback, or link-local addresses. A deployment that
598
+ // intentionally runs a Testaro worker inside a private network to test
599
+ // intranet pages sets this to "true"; a public deployment, whose Testaro
600
+ // workers may run on hosts with access to their own cloud metadata service,
601
+ // leaves it unset so private addresses are rejected by default (fail safe).
602
+ const allowsInternalTargets = (): boolean => process.env.ALLOW_INTERNAL_TARGETS === 'true';
603
+ // The signature of dns.lookup with {all: true}, extracted so tests can inject
604
+ // a fake resolver and avoid depending on real network/DNS access.
605
+ type LookupAll = (hostname: string, options: {all: true}) => Promise<{address: string; family: number}[]>;
606
+ // Returns whether the host a URL resolves to consists only of addresses this
607
+ // deployment is configured to allow as testing targets. This governs where
608
+ // the Testaro browser is allowed to navigate, so it is the control against
609
+ // submitting (or, via a redirect, being routed to) an unintended internal
610
+ // target such as a cloud metadata service or another host on a private
611
+ // network. It is deliberately independent of the syntax check that isURL
612
+ // performs, because it requires a DNS lookup, and therefore an internal
613
+ // deployment can use it to check both a requested URL (at submission time)
614
+ // and the actual, possibly redirected, URL of a report (at report-ingestion
615
+ // time). The lookup parameter defaults to the real dns.lookup and exists so
616
+ // tests can inject a fake resolver.
617
+ export const isAllowedTarget = async (url: string, lookup: LookupAll = dns.lookup): Promise<boolean> => {
618
+ // If this deployment allows internal targets, every syntactically valid URL is allowed.
619
+ if (allowsInternalTargets()) {
620
+ return true;
621
+ }
622
+ let hostname: string;
623
+ try {
624
+ hostname = new URL(url).hostname;
625
+ } catch {
626
+ return false;
627
+ }
628
+ // If the hostname is itself a literal IP address, check it directly.
629
+ if (net.isIP(hostname)) {
630
+ return !isNonPublicIP(hostname);
631
+ }
632
+ // Otherwise, resolve the hostname and reject it if any address it resolves
633
+ // to is non-public, since a hostname can resolve to more than one address
634
+ // and an attacker need control only one of them.
635
+ try {
636
+ const addresses = await lookup(hostname, {all: true});
637
+ return addresses.length > 0 && addresses.every(({address}) => !isNonPublicIP(address));
638
+ } catch {
639
+ // An unresolvable hostname cannot be a usable testing target either way.
640
+ return false;
641
+ }
642
+ };
557
643
  // Returns whether a string is the authorization code, the shared check every
558
644
  // manager-facing page and action uses to gate a submission. Centralized so all call
559
645
  // sites compare against process.env.AUTH_CODE the same way.
@@ -606,6 +692,31 @@ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Pr
606
692
  // Returns the test acts of a report.
607
693
  export const getTestActs = (report: UsableReport): AnnotatedAct[] =>
608
694
  report.acts.filter(act => act.type === 'test');
695
+ // Returns whether all URLs actually visited for a report are allowed targets.
696
+ // A URL submitted for testing can pass isAllowedTarget at submission time and
697
+ // still result in a report on a disallowed target, because the browser
698
+ // running in a worker can be redirected (possibly through DNS rebinding, so
699
+ // the same hostname resolves differently at fetch time) to a host this
700
+ // deployment does not allow as a testing target. Since each test act records
701
+ // the URL it actually visited (actualURL, defaulting to the requested URL of
702
+ // the job if a tool did not report one), checking every act here, right
703
+ // before a report is stored or served, is what actually closes that gap,
704
+ // independently of whatever happened during navigation.
705
+ export const isAllowedReport = async (
706
+ report: Partial<Report>, lookup: LookupAll = dns.lookup
707
+ ): Promise<boolean> => {
708
+ const acts = Array.isArray(report.acts) ? report.acts : [];
709
+ const testActs = acts.filter((act): act is AnnotatedAct => act?.type === 'test');
710
+ const urlsVisited = new Set(
711
+ testActs.map(act => act.actualURL ?? report.target?.url).filter((url): url is string => !!url)
712
+ );
713
+ for (const url of urlsVisited) {
714
+ if (!(await isAllowedTarget(url, lookup))) {
715
+ return false;
716
+ }
717
+ }
718
+ return true;
719
+ };
609
720
  // Returns the standard instances of a report's test acts, optionally filtered.
610
721
  export const getTestActInstances = (
611
722
  report: UsableReport,
@@ -0,0 +1,237 @@
1
+ /*
2
+ deleteNotesForm.test.ts
3
+ Unit tests for web/deleteNotesForm/index.ts.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, beforeEach, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ // ENVIRONMENT
14
+
15
+ const testDir = path.join(import.meta.dirname, '../../test/fixtures/deleteNotesForm');
16
+ const tutorialWebPath = path.join(testDir, 'tutorialWeb.json');
17
+ const tutorialAIPath = path.join(testDir, 'tutorialAI.json');
18
+ const featureRequestsPath = path.join(testDir, 'featureRequests.json');
19
+
20
+ const savedAuthCode = process.env.AUTH_CODE;
21
+ const savedTutorialWebPath = process.env.TUTORIAL_WEB_COMMENTS_PATH;
22
+ const savedTutorialAIPath = process.env.TUTORIAL_AI_COMMENTS_PATH;
23
+ const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
24
+
25
+ before(async () => {
26
+ process.env.AUTH_CODE = 'test-auth-code';
27
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = tutorialWebPath;
28
+ process.env.TUTORIAL_AI_COMMENTS_PATH = tutorialAIPath;
29
+ process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
30
+ await fs.mkdir(testDir, {recursive: true});
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ await fs.writeFile(tutorialWebPath, JSON.stringify([
35
+ {timeStamp: '260101T0000', content: 'A web tutorial comment'}
36
+ ]));
37
+ await fs.writeFile(tutorialAIPath, JSON.stringify([
38
+ {timeStamp: '260102T0000', content: 'An AI tutorial comment'}
39
+ ]));
40
+ await fs.writeFile(featureRequestsPath, JSON.stringify([
41
+ {timeStamp: '260103T0000', content: 'A feature request'}
42
+ ]));
43
+ });
44
+
45
+ import {answer} from './index.ts';
46
+
47
+ after(async () => {
48
+ if (savedAuthCode !== undefined) {
49
+ process.env.AUTH_CODE = savedAuthCode;
50
+ }
51
+ if (savedTutorialWebPath !== undefined) {
52
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = savedTutorialWebPath;
53
+ } else {
54
+ delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
55
+ }
56
+ if (savedTutorialAIPath !== undefined) {
57
+ process.env.TUTORIAL_AI_COMMENTS_PATH = savedTutorialAIPath;
58
+ } else {
59
+ delete process.env.TUTORIAL_AI_COMMENTS_PATH;
60
+ }
61
+ if (savedFeatureRequestsPath !== undefined) {
62
+ process.env.FEATURE_REQUESTS_PATH = savedFeatureRequestsPath;
63
+ } else {
64
+ delete process.env.FEATURE_REQUESTS_PATH;
65
+ }
66
+ await fs.rm(testDir, {recursive: true, force: true});
67
+ });
68
+
69
+ // TESTS
70
+
71
+ test('deleteNotesForm lists notes from all three sources on a GET request', async () => {
72
+ const result = await answer(null, '', 'GET');
73
+ assert.equal(result.status, 'ok');
74
+ assert.ok(result.answerPage!.includes('A web tutorial comment'));
75
+ assert.ok(result.answerPage!.includes('An AI tutorial comment'));
76
+ assert.ok(result.answerPage!.includes('A feature request'));
77
+ });
78
+
79
+ test('deleteNotesForm ignores a note query string on a GET request', async () => {
80
+ const result = await answer(
81
+ null,
82
+ `authCode=test-auth-code&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
83
+ 'GET'
84
+ );
85
+ assert.equal(result.status, 'ok');
86
+ // The note should NOT have been deleted by a mere GET request.
87
+ const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
88
+ assert.equal(notes.length, 1);
89
+ });
90
+
91
+ test('deleteNotesForm returns an error for an invalid auth code on a POST request', async () => {
92
+ const result = await answer(
93
+ null,
94
+ `authCode=wrong&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
95
+ 'POST'
96
+ );
97
+ assert.equal(result.status, 'error');
98
+ assert.equal(result.message, 'Invalid request');
99
+ const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
100
+ assert.equal(notes.length, 1);
101
+ });
102
+
103
+ test('deleteNotesForm deletes a selected web tutorial comment with a valid auth code', async () => {
104
+ const result = await answer(
105
+ null,
106
+ `authCode=test-auth-code&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
107
+ 'POST'
108
+ );
109
+ assert.equal(result.status, 'ok');
110
+ const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
111
+ assert.equal(notes.length, 0);
112
+ // The other sources are untouched.
113
+ const aiNotes = JSON.parse(await fs.readFile(tutorialAIPath, 'utf8'));
114
+ assert.equal(aiNotes.length, 1);
115
+ });
116
+
117
+ test('deleteNotesForm deletes selected notes across multiple sources in one submission', async () => {
118
+ const result = await answer(
119
+ null,
120
+ 'authCode=test-auth-code' +
121
+ `&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}` +
122
+ `&note=tutorialAI%09${encodeURIComponent('An AI tutorial comment')}` +
123
+ `&note=featureRequest%09${encodeURIComponent('A feature request')}`,
124
+ 'POST'
125
+ );
126
+ assert.equal(result.status, 'ok');
127
+ assert.equal(JSON.parse(await fs.readFile(tutorialWebPath, 'utf8')).length, 0);
128
+ assert.equal(JSON.parse(await fs.readFile(tutorialAIPath, 'utf8')).length, 0);
129
+ assert.equal(JSON.parse(await fs.readFile(featureRequestsPath, 'utf8')).length, 0);
130
+ });
131
+
132
+ test('deleteNotesForm leaves unselected notes in a source intact', async () => {
133
+ await fs.writeFile(tutorialWebPath, JSON.stringify([
134
+ {timeStamp: '260101T0000', content: 'Keep this one'},
135
+ {timeStamp: '260101T0001', content: 'Delete this one'}
136
+ ]));
137
+ const result = await answer(
138
+ null,
139
+ `authCode=test-auth-code&note=tutorialWeb%09${encodeURIComponent('Delete this one')}`,
140
+ 'POST'
141
+ );
142
+ assert.equal(result.status, 'ok');
143
+ const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
144
+ assert.equal(notes.length, 1);
145
+ assert.equal(notes[0].content, 'Keep this one');
146
+ });
147
+
148
+ test('deleteNotesForm reports no notes to delete when all sources are empty', async () => {
149
+ await fs.writeFile(tutorialWebPath, '[]');
150
+ await fs.writeFile(tutorialAIPath, '[]');
151
+ await fs.writeFile(featureRequestsPath, '[]');
152
+ const result = await answer(null, '', 'GET');
153
+ assert.equal(result.status, 'ok');
154
+ assert.ok(result.answerPage!.includes('There are no comments or feature requests to delete.'));
155
+ });
156
+
157
+ test('deleteNotesForm treats a missing source file as having no notes', async () => {
158
+ await fs.rm(featureRequestsPath, {force: true});
159
+ const result = await answer(null, '', 'GET');
160
+ assert.equal(result.status, 'ok');
161
+ assert.ok(result.answerPage!.includes('A web tutorial comment'));
162
+ });
163
+
164
+ test('deleteNotesForm escapes note content to prevent HTML injection', async () => {
165
+ await fs.writeFile(tutorialWebPath, JSON.stringify([
166
+ {timeStamp: '260101T0000', content: '<script>alert(1)</script>'}
167
+ ]));
168
+ const result = await answer(null, '', 'GET');
169
+ assert.equal(result.status, 'ok');
170
+ assert.ok(!result.answerPage!.includes('<script>'));
171
+ });
172
+
173
+ test('deleteNotesForm deletes a note whose content contains a literal tab character', async () => {
174
+ const content = 'Line one\tLine two, after an embedded tab';
175
+ await fs.writeFile(tutorialWebPath, JSON.stringify([
176
+ {timeStamp: '260101T0000', content}
177
+ ]));
178
+ const result = await answer(
179
+ null, `authCode=test-auth-code&note=tutorialWeb%09${encodeURIComponent(content)}`, 'POST'
180
+ );
181
+ assert.equal(result.status, 'ok');
182
+ const notes = JSON.parse(await fs.readFile(tutorialWebPath, 'utf8'));
183
+ assert.equal(notes.length, 0);
184
+ });
185
+
186
+ test('deleteNotesForm renders a checkbox value that round-trips a note with a literal tab', async () => {
187
+ const content = 'Has\ta\ttab';
188
+ await fs.writeFile(tutorialWebPath, JSON.stringify([
189
+ {timeStamp: '260101T0000', content}
190
+ ]));
191
+ const result = await answer(null, '', 'GET');
192
+ assert.equal(result.status, 'ok');
193
+ assert.ok(result.answerPage!.includes(`tutorialWeb\t${encodeURIComponent(content)}`));
194
+ });
195
+
196
+ test('deleteNotesForm returns an error when a note file cannot be written', async () => {
197
+ // Point the web tutorial comments path at a location whose parent is a file, so that
198
+ // creating the directory for it fails.
199
+ const blockerFile = path.join(testDir, 'blocker');
200
+ const unwritablePath = path.join(blockerFile, 'tutorialWeb.json');
201
+ await fs.writeFile(blockerFile, 'not a directory');
202
+ const savedPath = process.env.TUTORIAL_WEB_COMMENTS_PATH;
203
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = unwritablePath;
204
+ try {
205
+ const result = await answer(
206
+ null,
207
+ `authCode=test-auth-code&note=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}`,
208
+ 'POST'
209
+ );
210
+ assert.equal(result.status, 'error');
211
+ assert.ok(result.message?.includes('Deleting notes failed'));
212
+ } finally {
213
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = savedPath;
214
+ await fs.unlink(blockerFile).catch(() => {});
215
+ }
216
+ });
217
+
218
+ test('deleteNotesForm falls back to default paths when environment variables are not set', async () => {
219
+ const saved = {
220
+ tutorialWeb: process.env.TUTORIAL_WEB_COMMENTS_PATH,
221
+ tutorialAI: process.env.TUTORIAL_AI_COMMENTS_PATH,
222
+ featureRequest: process.env.FEATURE_REQUESTS_PATH
223
+ };
224
+ delete process.env.TUTORIAL_WEB_COMMENTS_PATH;
225
+ delete process.env.TUTORIAL_AI_COMMENTS_PATH;
226
+ delete process.env.FEATURE_REQUESTS_PATH;
227
+ try {
228
+ // None of the default db/ files exist in this environment, so the form should
229
+ // still render, treating each missing source as having no notes.
230
+ const result = await answer(null, '', 'GET');
231
+ assert.equal(result.status, 'ok');
232
+ } finally {
233
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = saved.tutorialWeb;
234
+ process.env.TUTORIAL_AI_COMMENTS_PATH = saved.tutorialAI;
235
+ process.env.FEATURE_REQUESTS_PATH = saved.featureRequest;
236
+ }
237
+ });
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="publisher" content="Jonathan Robert Pool">
7
+ <meta name="creator" content="Jonathan Robert Pool">
8
+ <meta name="keywords" content="report,accessibility,a11y">
9
+ <title>Delete notes | Kilotest</title>
10
+ <link rel="icon" href="/favicon.ico">
11
+ <link rel="stylesheet" href="/style.css">
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <h1><a href="/">Kilotest</a>: Delete notes</h1>
16
+ <p>__intro__</p>
17
+ <form action="/deleteNotesForm.html" method="post">
18
+ <fieldset>
19
+ <legend>Notes</legend>
20
+ __notes__
21
+ </fieldset>
22
+ <p><label>
23
+ Authorization code: <input size="3" minLength="3" maxlength="3" name="authCode" required__disabled__>
24
+ </label></p>
25
+ <p><button type="submit"__disabled__>Submit</button></p>
26
+ </form>
27
+ </main>
28
+ </body>
29
+ </html>
@@ -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
+ };
@@ -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>
@@ -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.