@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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/periodic-smoke-tests.yml +2 -2
- package/.github/workflows/publish-mcp.yml +1 -1
- package/README.md +5 -5
- package/api/requestFeature.test.ts +81 -3
- package/api/requestFeature.ts +46 -10
- package/api/requestRetest.test.ts +7 -0
- package/api/requestTest.test.ts +23 -0
- package/api/requestTest.ts +7 -1
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/SERVICE.md +27 -6
- package/env.example +8 -0
- package/index.test.ts +137 -0
- package/index.ts +52 -5
- package/linkValidation.test.ts +1 -0
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/test/setup.ts +5 -0
- package/util.test.ts +171 -13
- package/util.ts +127 -16
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/manage/index.html +1 -0
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/.claude/scheduled_tasks.lock +0 -1
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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: {
|
|
220
|
+
comments: {content: string}[], content: string
|
|
218
221
|
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
219
|
-
//
|
|
220
|
-
|
|
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
|
|
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¬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>
|
|
@@ -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
|
+
};
|
package/web/manage/index.html
CHANGED
|
@@ -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>
|
package/web/tutorialAI/index.ts
CHANGED
|
@@ -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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
63
|
+
// Add the comment to the existing ones.
|
|
69
64
|
comments.push({
|
|
70
|
-
|
|
65
|
+
timeStamp: getNowStamp(),
|
|
71
66
|
content: sanitized
|
|
72
67
|
});
|
|
73
68
|
// Ensure the comments directory exists.
|