@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.
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +100 -4
- package/api/requestFeature.ts +57 -13
- package/api/requestRetest.test.ts +46 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.test.ts +7 -0
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/BACKLOG.md +14 -2
- package/docs/SERVICE.md +50 -7
- package/index.test.ts +171 -7
- package/index.ts +103 -23
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +1 -1
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/util.test.ts +78 -13
- package/util.ts +35 -16
- package/web/ai0BalanceForm/index.ts +2 -2
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/enqueue/index.ts +33 -42
- package/web/hideReportForm/index.ts +4 -2
- package/web/manage/index.html +1 -0
- package/web/metrics/index.ts +3 -2
- package/web/reannotate/index.ts +2 -1
- package/web/renewWCAG/index.ts +2 -2
- package/web/reportDeletion.ts +3 -2
- package/web/showHiddenReportsForm/index.ts +2 -2
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/web/unhideReportForm/index.ts +4 -2
- package/.claude/scheduled_tasks.lock +0 -1
package/robots.txt
CHANGED
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
|
|
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('
|
|
239
|
+
const probe = await sendMCPRequest('POST', 'application/json', mcpInitialize);
|
|
235
240
|
if (isMCPErrorResponse(probe)) {
|
|
236
|
-
console.log(`PASS:
|
|
241
|
+
console.log(`PASS: POST ${mcpPath} bad accept -> ${probe.statusCode} (JSON-RPC error)`);
|
|
237
242
|
}
|
|
238
243
|
else {
|
|
239
|
-
console.log(`FAIL:
|
|
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:
|
|
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/util.test.ts
CHANGED
|
@@ -9,10 +9,20 @@ import {test, before, after} from 'node:test';
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
// Blank the alert configuration unconditionally, before util.ts (which processTestRequest
|
|
14
|
+
// calls sendAlert from) is ever imported below, so this file sends no real alert emails
|
|
15
|
+
// even when run directly (e.g. `npx tsx --test util.test.ts`) rather than via `npm test`,
|
|
16
|
+
// which normally guards against this via test/setup.ts.
|
|
17
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
18
|
+
process.env[key] = '';
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
import {
|
|
13
22
|
annotateReportObject,
|
|
14
23
|
checkCommentDuplicate,
|
|
15
24
|
checkCommentLength,
|
|
25
|
+
checkLength,
|
|
16
26
|
createLock,
|
|
17
27
|
dbPath,
|
|
18
28
|
getAgoDays,
|
|
@@ -45,6 +55,7 @@ import {
|
|
|
45
55
|
isReportAvailable,
|
|
46
56
|
isTimeStamp,
|
|
47
57
|
isURL,
|
|
58
|
+
isValidAuthCode,
|
|
48
59
|
jobsPath,
|
|
49
60
|
makeBreakable,
|
|
50
61
|
minifyURL,
|
|
@@ -83,31 +94,49 @@ test('checkCommentLength rejects a comment longer than 1000 characters', () => {
|
|
|
83
94
|
});
|
|
84
95
|
});
|
|
85
96
|
|
|
86
|
-
test('
|
|
87
|
-
const result =
|
|
97
|
+
test('checkLength accepts text at the minimum bound', () => {
|
|
98
|
+
const result = checkLength('a'.repeat(5), 5, 10, 'reason');
|
|
88
99
|
assert.deepEqual(result, {status: 'ok'});
|
|
89
100
|
});
|
|
90
101
|
|
|
91
|
-
test('
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
test('checkLength accepts text at the maximum bound', () => {
|
|
103
|
+
const result = checkLength('a'.repeat(10), 5, 10, 'reason');
|
|
104
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('checkLength rejects text shorter than the minimum bound', () => {
|
|
108
|
+
const result = checkLength('a'.repeat(4), 5, 10, 'reason');
|
|
109
|
+
assert.deepEqual(result, {
|
|
110
|
+
status: 'error',
|
|
111
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('checkLength rejects text longer than the maximum bound', () => {
|
|
116
|
+
const result = checkLength('a'.repeat(11), 5, 10, 'reason');
|
|
95
117
|
assert.deepEqual(result, {
|
|
96
118
|
status: 'error',
|
|
97
|
-
message: '
|
|
119
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
98
120
|
});
|
|
99
121
|
});
|
|
100
122
|
|
|
101
|
-
test('checkCommentDuplicate accepts a comment
|
|
123
|
+
test('checkCommentDuplicate accepts a comment with no existing comments', () => {
|
|
124
|
+
const result = checkCommentDuplicate([], 'a'.repeat(20));
|
|
125
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('checkCommentDuplicate rejects a comment identical to one already stored, regardless of when', () => {
|
|
102
129
|
const content = 'a'.repeat(20);
|
|
103
|
-
const
|
|
104
|
-
const comments = [{timeStamp: oldTimeStamp, content}];
|
|
130
|
+
const comments = [{content}];
|
|
105
131
|
const result = checkCommentDuplicate(comments, content);
|
|
106
|
-
assert.deepEqual(result, {
|
|
132
|
+
assert.deepEqual(result, {
|
|
133
|
+
status: 'error',
|
|
134
|
+
message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
135
|
+
});
|
|
107
136
|
});
|
|
108
137
|
|
|
109
|
-
test('checkCommentDuplicate accepts a comment that differs from a
|
|
110
|
-
const comments = [{
|
|
138
|
+
test('checkCommentDuplicate accepts a comment that differs from a stored one', () => {
|
|
139
|
+
const comments = [{content: 'a'.repeat(20)}];
|
|
111
140
|
const result = checkCommentDuplicate(comments, 'b'.repeat(20));
|
|
112
141
|
assert.deepEqual(result, {status: 'ok'});
|
|
113
142
|
});
|
|
@@ -337,6 +366,42 @@ test('isURL returns false for an invalid URL', () => {
|
|
|
337
366
|
assert.equal(isURL('not-a-url'), false);
|
|
338
367
|
});
|
|
339
368
|
|
|
369
|
+
test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
|
|
370
|
+
const saved = process.env.AUTH_CODE;
|
|
371
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
372
|
+
try {
|
|
373
|
+
assert.equal(isValidAuthCode('secret-code'), true);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
if (saved === undefined) {
|
|
377
|
+
delete process.env.AUTH_CODE;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
process.env.AUTH_CODE = saved;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('isValidAuthCode returns false when the code does not match AUTH_CODE', () => {
|
|
386
|
+
const saved = process.env.AUTH_CODE;
|
|
387
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
388
|
+
try {
|
|
389
|
+
assert.equal(isValidAuthCode('wrong-code'), false);
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
if (saved === undefined) {
|
|
393
|
+
delete process.env.AUTH_CODE;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
process.env.AUTH_CODE = saved;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('isValidAuthCode returns false for an undefined code', () => {
|
|
402
|
+
assert.equal(isValidAuthCode(undefined), false);
|
|
403
|
+
});
|
|
404
|
+
|
|
340
405
|
test('makeBreakable inserts wbr before non-initial slashes', () => {
|
|
341
406
|
assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
|
|
342
407
|
});
|
package/util.ts
CHANGED
|
@@ -173,6 +173,24 @@ export type ReportData = {
|
|
|
173
173
|
const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'});
|
|
174
174
|
// Sorts strings alphabetically and case-insensitively.
|
|
175
175
|
const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b));
|
|
176
|
+
// Returns whether a string's length is within the given bounds. Generic length check
|
|
177
|
+
// shared by any field with a min/max character-count rule (e.g. a request description
|
|
178
|
+
// or reason), so each caller states its own bounds and label instead of hardcoding an
|
|
179
|
+
// ad hoc length comparison.
|
|
180
|
+
export const checkLength = (
|
|
181
|
+
text: string, min: number, max: number, label: string
|
|
182
|
+
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
183
|
+
const {length} = text;
|
|
184
|
+
// If the length is invalid:
|
|
185
|
+
if (length < min || length > max) {
|
|
186
|
+
// Return the applicable error message.
|
|
187
|
+
return {
|
|
188
|
+
status: 'error',
|
|
189
|
+
message: `The ${label} must be between ${min} and ${max} characters long`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {status: 'ok'};
|
|
193
|
+
};
|
|
176
194
|
// Returns whether a comment's raw length is within the 20-to-1000-character bounds.
|
|
177
195
|
export const checkCommentLength = (
|
|
178
196
|
content: string
|
|
@@ -189,27 +207,22 @@ export const checkCommentLength = (
|
|
|
189
207
|
}
|
|
190
208
|
return {status: 'ok'};
|
|
191
209
|
};
|
|
192
|
-
// Returns whether a comment repeats, verbatim, one of the given comments
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
210
|
+
// Returns whether a comment repeats, verbatim, one of the given comments, regardless
|
|
211
|
+
// of when the earlier one was submitted: a legitimate need to resubmit exactly the
|
|
212
|
+
// same content never arises (the near-term case is a browser "Back" resubmission),
|
|
213
|
+
// so storing a second copy would only ever be redundant. The content passed in
|
|
214
|
+
// should be the content as it will be stored (e.g. after sanitization), so that it
|
|
215
|
+
// is compared on the same basis as the stored comments. Content, not any time
|
|
216
|
+
// stamp, is what this checks and what identifies a comment as unique.
|
|
198
217
|
export const checkCommentDuplicate = (
|
|
199
|
-
comments: {
|
|
218
|
+
comments: {content: string}[], content: string
|
|
200
219
|
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
// of comments.json.
|
|
204
|
-
const duplicates = comments
|
|
205
|
-
.filter(comment => getDateTime(comment.timeStamp)!.getTime() > Date.now() - 1000000)
|
|
206
|
-
.filter(comment => comment.content === content);
|
|
207
|
-
// If the comment duplicates a recent one:
|
|
208
|
-
if (duplicates.length) {
|
|
220
|
+
// If the comment duplicates one already stored:
|
|
221
|
+
if (comments.some(comment => comment.content === content)) {
|
|
209
222
|
// Return this.
|
|
210
223
|
return {
|
|
211
224
|
status: 'error',
|
|
212
|
-
message: 'Your comment
|
|
225
|
+
message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
213
226
|
};
|
|
214
227
|
}
|
|
215
228
|
// Otherwise, the comment is not a duplicate.
|
|
@@ -536,6 +549,12 @@ export const isURL = (string: string): boolean => {
|
|
|
536
549
|
return false;
|
|
537
550
|
}
|
|
538
551
|
};
|
|
552
|
+
// Returns whether a string is the authorization code, the shared check every
|
|
553
|
+
// manager-facing page and action uses to gate a submission. Centralized so all call
|
|
554
|
+
// sites compare against process.env.AUTH_CODE the same way.
|
|
555
|
+
export const isValidAuthCode = (authCode: string | undefined | null): boolean => {
|
|
556
|
+
return !!authCode && authCode === process.env.AUTH_CODE;
|
|
557
|
+
};
|
|
539
558
|
// Makes a string breakable before non-initial slashes.
|
|
540
559
|
export const makeBreakable = (string: string): string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
|
|
541
560
|
// Minifies a URL for duplicate detection.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import {balancePath} from '../../balances.ts';
|
|
10
|
-
import {getJSON, populateTemplate} from '../../util.ts';
|
|
10
|
+
import {getJSON, isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
22
22
|
// If the form has been submitted:
|
|
23
23
|
if (method === 'POST' && newBalanceString) {
|
|
24
24
|
// If the authorization code is valid:
|
|
25
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
26
26
|
const newBalance = Number.parseFloat(newBalanceString);
|
|
27
27
|
// If the new balance is valid:
|
|
28
28
|
if (
|
|
@@ -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¬e=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¬e=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¬e=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
|
+
`¬e=tutorialWeb%09${encodeURIComponent('A web tutorial comment')}` +
|
|
122
|
+
`¬e=tutorialAI%09${encodeURIComponent('An AI tutorial comment')}` +
|
|
123
|
+
`¬e=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¬e=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¬e=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¬e=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>
|