@jrpool/kilotest 51.1.0 → 55.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/.devin/config.local.json +4 -1
- package/.github/workflows/ci.yml +5 -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 +36 -0
- package/api/requestFeature.ts +26 -4
- package/api/requestRetest.test.ts +18 -0
- package/api/requestRetest.ts +4 -0
- package/api/requestTest.test.ts +50 -0
- package/api/requestTest.ts +25 -1
- package/capability.md +2 -2
- package/docs/AI-TOOLS.md +3 -3
- package/docs/SERVICE.md +23 -2
- package/env.example +32 -0
- package/index.test.ts +126 -0
- package/index.ts +74 -23
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/mcp.test.ts +41 -4
- package/mcp.ts +67 -17
- package/openapi.yaml +5 -5
- package/package.json +2 -1
- package/scripts/generate-openapi.ts +1 -1
- package/test/setup.ts +5 -0
- package/util.test.ts +429 -0
- package/util.ts +182 -3
- package/web/requestRetest/index.ts +9 -0
- package/web/requestRetest/requestRetest.test.ts +14 -0
- package/web/requestTest/index.ts +9 -0
- package/web/requestTest/requestTest.test.ts +16 -0
- package/web/tutorialAI/index.ts +25 -3
- package/web/tutorialAI/tutorialAI.test.ts +32 -0
- package/web/tutorialWeb/index.ts +25 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
package/util.ts
CHANGED
|
@@ -12,7 +12,10 @@ 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 ipaddr from 'ipaddr.js';
|
|
18
|
+
import net from 'node:net';
|
|
16
19
|
import path from 'node:path';
|
|
17
20
|
import querystring from 'node:querystring';
|
|
18
21
|
/* c8 ignore stop */
|
|
@@ -77,7 +80,7 @@ export type TestRequests = Record<string, TestRequest[]>;
|
|
|
77
80
|
|
|
78
81
|
// Test request addition result.
|
|
79
82
|
export type TestRequestResult
|
|
80
|
-
= 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'ok';
|
|
83
|
+
= 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'queueFull' | 'ok';
|
|
81
84
|
|
|
82
85
|
// Target of a new-test or retest request: a description and URL for a new-test request,
|
|
83
86
|
// or the timeStamp and jobID of the cited report for a retest request.
|
|
@@ -549,6 +552,118 @@ export const isURL = (string: string): boolean => {
|
|
|
549
552
|
return false;
|
|
550
553
|
}
|
|
551
554
|
};
|
|
555
|
+
// The ipaddr.js range labels, for each address family, that are not part of
|
|
556
|
+
// the public, globally routable address space and so must never be a testing
|
|
557
|
+
// target. Sourced from ipaddr.js's own IPv4/IPv6 SpecialRanges tables, which
|
|
558
|
+
// track the IANA special-purpose address registries (e.g. RFC 1918 private
|
|
559
|
+
// ranges, RFC 5737/3849 documentation ranges, RFC 6598 carrier-grade NAT,
|
|
560
|
+
// RFC 6890 link-local, including the 169.254.169.254 cloud-metadata address).
|
|
561
|
+
// A version bump of ipaddr.js is not pinned or specially reviewed here (this
|
|
562
|
+
// deployment always installs the latest of every dependency); instead, the
|
|
563
|
+
// isAllowedTarget/isAllowedRedirectTarget tests enumerate specific addresses
|
|
564
|
+
// in every one of these ranges, so a future release that narrowed this
|
|
565
|
+
// coverage would fail those tests rather than silently allowing an address
|
|
566
|
+
// that should be rejected.
|
|
567
|
+
const nonPublicIPv4Ranges = new Set([
|
|
568
|
+
'unspecified', 'broadcast', 'multicast', 'linkLocal', 'loopback', 'carrierGradeNat', 'private', 'reserved'
|
|
569
|
+
]);
|
|
570
|
+
const nonPublicIPv6Ranges = new Set([
|
|
571
|
+
'unspecified', 'linkLocal', 'multicast', 'loopback', 'uniqueLocal', 'deprecatedSiteLocal', 'discard',
|
|
572
|
+
'rfc6145', 'rfc6052', '6to4', 'teredo', 'benchmarking', 'amt', 'as112v6', 'deprecatedOrchid', 'orchid2'
|
|
573
|
+
]);
|
|
574
|
+
// Returns whether an IP address (v4 or v6) is outside the public address
|
|
575
|
+
// space, i.e. is a loopback, private, link-local, or other reserved address
|
|
576
|
+
// that a public deployment should never intend to test.
|
|
577
|
+
const isNonPublicIP = (address: string): boolean => {
|
|
578
|
+
let parsed;
|
|
579
|
+
try {
|
|
580
|
+
// process(), rather than parse(), so an IPv4-mapped IPv6 address (e.g.
|
|
581
|
+
// ::ffff:10.0.0.5) is unwrapped and classified by its embedded IPv4 address.
|
|
582
|
+
parsed = ipaddr.process(address);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// Not a recognizable IP address, so treat it as non-public, i.e. as unsafe.
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
const ranges = parsed.kind() === 'ipv4' ? nonPublicIPv4Ranges : nonPublicIPv6Ranges;
|
|
589
|
+
return ranges.has(parsed.range());
|
|
590
|
+
};
|
|
591
|
+
// Returns whether ALLOW_INTERNAL_TARGETS opts this deployment in to testing
|
|
592
|
+
// pages at private, loopback, or link-local addresses. A deployment that
|
|
593
|
+
// intentionally runs a Testaro worker inside a private network to test
|
|
594
|
+
// intranet pages sets this to "true"; a public deployment, whose Testaro
|
|
595
|
+
// workers may run on hosts with access to their own cloud metadata service,
|
|
596
|
+
// leaves it unset so private addresses are rejected by default (fail safe).
|
|
597
|
+
const allowsInternalTargets = (): boolean => process.env.ALLOW_INTERNAL_TARGETS === 'true';
|
|
598
|
+
// The signature of dns.lookup with {all: true}, extracted so tests can inject
|
|
599
|
+
// a fake resolver and avoid depending on real network/DNS access.
|
|
600
|
+
type LookupAll = (hostname: string, options: {all: true}) => Promise<{address: string; family: number}[]>;
|
|
601
|
+
// Returns whether the host a URL resolves to consists only of addresses this
|
|
602
|
+
// deployment is configured to allow as testing targets. This governs where
|
|
603
|
+
// the Testaro browser is allowed to navigate, so it is the control against
|
|
604
|
+
// submitting (or, via a redirect, being routed to) an unintended internal
|
|
605
|
+
// target such as a cloud metadata service or another host on a private
|
|
606
|
+
// network. It is deliberately independent of the syntax check that isURL
|
|
607
|
+
// performs, because it requires a DNS lookup, and therefore an internal
|
|
608
|
+
// deployment can use it to check both a requested URL (at submission time)
|
|
609
|
+
// and the actual, possibly redirected, URL of a report (at report-ingestion
|
|
610
|
+
// time). The lookup parameter defaults to the real dns.lookup and exists so
|
|
611
|
+
// tests can inject a fake resolver.
|
|
612
|
+
export const isAllowedTarget = async (url: string, lookup: LookupAll = dns.lookup): Promise<boolean> => {
|
|
613
|
+
// If this deployment allows internal targets, every syntactically valid URL is allowed.
|
|
614
|
+
if (allowsInternalTargets()) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
let hostname: string;
|
|
618
|
+
try {
|
|
619
|
+
// URL.hostname keeps the surrounding brackets of a literal IPv6 host (e.g.
|
|
620
|
+
// "[::1]"), which net.isIP and dns.lookup both reject, so they are stripped here.
|
|
621
|
+
hostname = new URL(url).hostname.replace(/^\[(.*)\]$/, '$1');
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
// If the hostname is itself a literal IP address, check it directly.
|
|
626
|
+
if (net.isIP(hostname)) {
|
|
627
|
+
return !isNonPublicIP(hostname);
|
|
628
|
+
}
|
|
629
|
+
// Otherwise, resolve the hostname and reject it if any address it resolves
|
|
630
|
+
// to is non-public, since a hostname can resolve to more than one address
|
|
631
|
+
// and an attacker need control only one of them.
|
|
632
|
+
try {
|
|
633
|
+
const addresses = await lookup(hostname, {all: true});
|
|
634
|
+
return addresses.length > 0 && addresses.every(({address}) => !isNonPublicIP(address));
|
|
635
|
+
} catch {
|
|
636
|
+
// An unresolvable hostname cannot be a usable testing target either way.
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
// Returns whether a URL, when actually requested, resolves through any
|
|
641
|
+
// redirects to a final response whose URL is an allowed target. isAllowedTarget
|
|
642
|
+
// only resolves DNS, so it cannot see an application-layer redirect (e.g. a
|
|
643
|
+
// legitimately resolving host issuing a 302 to a private address); this
|
|
644
|
+
// check is what can catch that at request time, before a job is ever queued.
|
|
645
|
+
// It cannot catch a redirect introduced after it runs, e.g. an attacker who
|
|
646
|
+
// reconfigures the target host once a request is accepted, so isAllowedReport
|
|
647
|
+
// remains the backstop that re-checks the URL a worker actually visited.
|
|
648
|
+
export const isAllowedRedirectTarget = async (
|
|
649
|
+
url: string, fetchImpl: typeof fetch = fetch, lookup: LookupAll = dns.lookup
|
|
650
|
+
): Promise<boolean> => {
|
|
651
|
+
// If this deployment allows internal targets, every syntactically valid URL is
|
|
652
|
+
// allowed, and no fetch is needed, consistent with isAllowedTarget's own early return.
|
|
653
|
+
if (allowsInternalTargets()) {
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
let response: Response;
|
|
657
|
+
try {
|
|
658
|
+
// redirect: 'follow' is the default, but named here because observing
|
|
659
|
+
// where redirects lead is the entire purpose of this request.
|
|
660
|
+
response = await fetchImpl(url, {redirect: 'follow', signal: AbortSignal.timeout(10000)});
|
|
661
|
+
} catch {
|
|
662
|
+
// An unreachable or errored target cannot be verified as safe either way.
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
return isAllowedTarget(response.url, lookup);
|
|
666
|
+
};
|
|
552
667
|
// Returns whether a string is the authorization code, the shared check every
|
|
553
668
|
// manager-facing page and action uses to gate a submission. Centralized so all call
|
|
554
669
|
// sites compare against process.env.AUTH_CODE the same way.
|
|
@@ -601,6 +716,31 @@ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Pr
|
|
|
601
716
|
// Returns the test acts of a report.
|
|
602
717
|
export const getTestActs = (report: UsableReport): AnnotatedAct[] =>
|
|
603
718
|
report.acts.filter(act => act.type === 'test');
|
|
719
|
+
// Returns whether all URLs actually visited for a report are allowed targets.
|
|
720
|
+
// A URL submitted for testing can pass isAllowedTarget at submission time and
|
|
721
|
+
// still result in a report on a disallowed target, because the browser
|
|
722
|
+
// running in a worker can be redirected (possibly through DNS rebinding, so
|
|
723
|
+
// the same hostname resolves differently at fetch time) to a host this
|
|
724
|
+
// deployment does not allow as a testing target. Since each test act records
|
|
725
|
+
// the URL it actually visited (actualURL, defaulting to the requested URL of
|
|
726
|
+
// the job if a tool did not report one), checking every act here, right
|
|
727
|
+
// before a report is stored or served, is what actually closes that gap,
|
|
728
|
+
// independently of whatever happened during navigation.
|
|
729
|
+
export const isAllowedReport = async (
|
|
730
|
+
report: Partial<Report>, lookup: LookupAll = dns.lookup
|
|
731
|
+
): Promise<boolean> => {
|
|
732
|
+
const acts = Array.isArray(report.acts) ? report.acts : [];
|
|
733
|
+
const testActs = acts.filter((act): act is AnnotatedAct => act?.type === 'test');
|
|
734
|
+
const urlsVisited = new Set(
|
|
735
|
+
testActs.map(act => act.actualURL ?? report.target?.url).filter((url): url is string => !!url)
|
|
736
|
+
);
|
|
737
|
+
for (const url of urlsVisited) {
|
|
738
|
+
if (!(await isAllowedTarget(url, lookup))) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
};
|
|
604
744
|
// Returns the standard instances of a report's test acts, optionally filtered.
|
|
605
745
|
export const getTestActInstances = (
|
|
606
746
|
report: UsableReport,
|
|
@@ -954,6 +1094,35 @@ const getApprovedJobProperty = async (
|
|
|
954
1094
|
// its addition to testRequests.json are both decided inside the lock, as a single
|
|
955
1095
|
// atomic operation, so no caller-visible seam exists where an approvability decision
|
|
956
1096
|
// could be based on stale information about testRequests.json.
|
|
1097
|
+
// Returns the non-negative-integer value of an environment variable, or a default if the
|
|
1098
|
+
// variable is unset, empty, or not a non-negative integer. Read at call time, not cached,
|
|
1099
|
+
// so a variable set (e.g. in .env) after this module first loads still takes effect. Used
|
|
1100
|
+
// by the "inbox full" caps below (test requests, tutorial comments, feature requests),
|
|
1101
|
+
// which different Kilotest deployments' maintainers may reasonably want sized differently.
|
|
1102
|
+
// A value of 0 means no limit, mirroring how these caps are checked (never reached).
|
|
1103
|
+
export const getEnvMax = (name: string, defaultValue: number): number => {
|
|
1104
|
+
const raw = process.env[name];
|
|
1105
|
+
if (!raw) {
|
|
1106
|
+
return defaultValue;
|
|
1107
|
+
}
|
|
1108
|
+
const parsed = Number(raw);
|
|
1109
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : defaultValue;
|
|
1110
|
+
};
|
|
1111
|
+
// Maximum number of test/retest requests awaiting approval at once, across all URLs.
|
|
1112
|
+
// Once reached, a new, distinct request is rejected rather than added, so the pending
|
|
1113
|
+
// queue and the manager alert emails it generates cannot grow without bound (see GitHub
|
|
1114
|
+
// issue #3, abuse type 3, repeated/automated test-request submissions). Modeled on a full
|
|
1115
|
+
// mailbox rejecting new mail, rather than on a submission rate limit, since it is the
|
|
1116
|
+
// backlog itself, not how quickly it grows, that costs the manager review time and risks
|
|
1117
|
+
// unbounded storage. Configurable via TEST_REQUEST_QUEUE_MAX, since different Kilotest
|
|
1118
|
+
// deployments' maintainers may want a different size; a value of 0 means no limit.
|
|
1119
|
+
const testRequestQueueMax = () => getEnvMax('TEST_REQUEST_QUEUE_MAX', 20);
|
|
1120
|
+
// Returns a cap for display in an alert message: the cap itself, or "no limit" if the
|
|
1121
|
+
// cap is 0.
|
|
1122
|
+
export const describeMax = (max: number): string => max === 0 ? 'no limit' : String(max);
|
|
1123
|
+
// Returns the total number of test/retest requests awaiting approval, across all URLs.
|
|
1124
|
+
const getPendingTestRequestCount = (testRequests: TestRequests): number =>
|
|
1125
|
+
Object.values(testRequests).reduce((total, requests) => total + requests.length, 0);
|
|
957
1126
|
export const processTestRequest = (
|
|
958
1127
|
reason: string,
|
|
959
1128
|
target: TestRequestTarget
|
|
@@ -1024,6 +1193,13 @@ export const processTestRequest = (
|
|
|
1024
1193
|
return {result: 'retest', description, url};
|
|
1025
1194
|
}
|
|
1026
1195
|
}
|
|
1196
|
+
// Otherwise, i.e. if the request is genuinely new, if the queue of requests awaiting
|
|
1197
|
+
// approval is already full (a cap of 0 means no limit, so the queue is never full):
|
|
1198
|
+
const queueMax = testRequestQueueMax();
|
|
1199
|
+
if (queueMax > 0 && getPendingTestRequestCount(requests) >= queueMax) {
|
|
1200
|
+
// Return this.
|
|
1201
|
+
return {result: 'queueFull', description, url};
|
|
1202
|
+
}
|
|
1027
1203
|
// The request is eligible, so get the requests awaiting approval.
|
|
1028
1204
|
const testRequests = await getTestRequests();
|
|
1029
1205
|
// Add the request to them, initializing them with the URL if necessary.
|
|
@@ -1036,10 +1212,13 @@ export const processTestRequest = (
|
|
|
1036
1212
|
await fs.writeFile(testRequestsPath(), getJSON(testRequests));
|
|
1037
1213
|
// Get an email-safe version of the reason.
|
|
1038
1214
|
const plainReason = getPlainText(reason);
|
|
1039
|
-
// Alert a manager
|
|
1215
|
+
// Alert a manager, including the resulting queue size, so a maintainer who has been
|
|
1216
|
+
// away sees at a glance how urgently the queue needs review (approval or rejection)
|
|
1217
|
+
// rather than learning this only once it is already full.
|
|
1040
1218
|
await sendAlert(
|
|
1041
1219
|
`Kilotest: new ${requestType} request awaits approval`,
|
|
1042
|
-
`Page description: ${description}\nURL: ${url}\nReason: ${plainReason}`
|
|
1220
|
+
`Page description: ${description}\nURL: ${url}\nReason: ${plainReason}\n` +
|
|
1221
|
+
`Requests now awaiting approval: ${getPendingTestRequestCount(testRequests)} of ${describeMax(queueMax)}`
|
|
1043
1222
|
);
|
|
1044
1223
|
// Return success.
|
|
1045
1224
|
return {result: 'ok', description, url};
|
|
@@ -51,6 +51,15 @@ export const answer = async (pageArgs: string, reason: string) => {
|
|
|
51
51
|
message: 'A later report about the page is already available'
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
// Otherwise, if the queue of requests awaiting approval is full:
|
|
55
|
+
else if (result === 'queueFull') {
|
|
56
|
+
// Report this, with a fallback channel, since this request cannot be queued here.
|
|
57
|
+
return {
|
|
58
|
+
status: 'error',
|
|
59
|
+
message: 'Too many requests are awaiting approval right now. Please try again later, ' +
|
|
60
|
+
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
54
63
|
// Otherwise, i.e. if a request to test a page with the same description or URL is approved:
|
|
55
64
|
else {
|
|
56
65
|
// Report this.
|
|
@@ -128,3 +128,17 @@ test('answer returns an error when a matching job is already claimed or queued',
|
|
|
128
128
|
}
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test('answer returns an error with a fallback channel when the request queue is full', async () => {
|
|
132
|
+
const {answer} = await import('./index.ts');
|
|
133
|
+
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
134
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
135
|
+
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Because filler'
|
|
136
|
+
}));
|
|
137
|
+
await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
|
|
138
|
+
const result: any = await answer('260101T0001/ct', 'Because changes were made');
|
|
139
|
+
assert.equal(result.status, 'error');
|
|
140
|
+
assert.ok(result.message.startsWith('Too many requests are awaiting approval right now.'));
|
|
141
|
+
assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
|
|
142
|
+
assert.ok(result.message.includes('info@kilotest.com'));
|
|
143
|
+
});
|
|
144
|
+
|
package/web/requestTest/index.ts
CHANGED
|
@@ -41,6 +41,15 @@ export const answer = async (description: string, url: string, reason: string) =
|
|
|
41
41
|
message: 'A report about the page is already available'
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
// Otherwise, if the queue of requests awaiting approval is full:
|
|
45
|
+
else if (result === 'queueFull') {
|
|
46
|
+
// Report this, with a fallback channel, since this request cannot be queued here.
|
|
47
|
+
return {
|
|
48
|
+
status: 'error',
|
|
49
|
+
message: 'Too many requests are awaiting approval right now. Please try again later, ' +
|
|
50
|
+
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
// Otherwise, i.e. if a request to test a page with the same description or URL is approved:
|
|
45
54
|
else {
|
|
46
55
|
// Report this.
|
|
@@ -128,3 +128,19 @@ test('answer returns an error when a report already exists for the page', {timeo
|
|
|
128
128
|
assert.equal(result.status, 'error');
|
|
129
129
|
assert.equal(result.message, 'A report about the page is already available');
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
test('answer returns an error with a fallback channel when the request queue is full', {timeout: 500}, async () => {
|
|
133
|
+
const {answer} = await import('./index.ts');
|
|
134
|
+
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
135
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
136
|
+
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Because filler'
|
|
137
|
+
}));
|
|
138
|
+
await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
|
|
139
|
+
const result: any = await answer(
|
|
140
|
+
'One Too Many Page', 'https://example.com/one-too-many', 'Because accessibility'
|
|
141
|
+
);
|
|
142
|
+
assert.equal(result.status, 'error');
|
|
143
|
+
assert.ok(result.message.startsWith('Too many requests are awaiting approval right now.'));
|
|
144
|
+
assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
|
|
145
|
+
assert.ok(result.message.includes('info@kilotest.com'));
|
|
146
|
+
});
|
package/web/tutorialAI/index.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {sendAlert} from '../../alerts.ts';
|
|
9
|
-
import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
|
|
9
|
+
import {checkCommentDuplicate, checkCommentLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
13
|
// FUNCTIONS (helpers)
|
|
14
14
|
|
|
15
15
|
const getCommentsPath = () => process.env.TUTORIAL_AI_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialAI.json');
|
|
16
|
+
// Maximum number of comments held at once. Once reached, a new, distinct comment is
|
|
17
|
+
// rejected rather than stored, so the comments file and the manager alert emails it
|
|
18
|
+
// generates cannot grow without bound (modeled on a full mailbox rejecting new mail; see
|
|
19
|
+
// GitHub issue #3, abuse type 3). Configurable via TUTORIAL_AI_COMMENTS_MAX, since
|
|
20
|
+
// different Kilotest deployments' maintainers may want a different size; a value of 0
|
|
21
|
+
// means no limit.
|
|
22
|
+
const getCommentsMax = () => getEnvMax('TUTORIAL_AI_COMMENTS_MAX', 20);
|
|
16
23
|
|
|
17
24
|
// FUNCTIONS
|
|
18
25
|
|
|
@@ -60,6 +67,17 @@ export const handleComment = async (content: unknown) => {
|
|
|
60
67
|
if (duplicateCheck.status === 'error') {
|
|
61
68
|
return duplicateCheck;
|
|
62
69
|
}
|
|
70
|
+
// If the comments already on file have reached the cap (a cap of 0 means no limit, so
|
|
71
|
+
// the comments are never full):
|
|
72
|
+
const commentsMax = getCommentsMax();
|
|
73
|
+
if (commentsMax > 0 && comments.length >= commentsMax) {
|
|
74
|
+
// Report this, with a fallback channel, since this comment cannot be stored here.
|
|
75
|
+
return {
|
|
76
|
+
status: 'error',
|
|
77
|
+
message: 'Too many comments are awaiting review right now. Please try again later, ' +
|
|
78
|
+
'or post your comment at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
63
81
|
// Add the comment to the existing ones.
|
|
64
82
|
comments.push({
|
|
65
83
|
timeStamp: getNowStamp(),
|
|
@@ -69,7 +87,11 @@ export const handleComment = async (content: unknown) => {
|
|
|
69
87
|
await fs.mkdir(path.dirname(commentsPath), {recursive: true});
|
|
70
88
|
// Save the revised comments.
|
|
71
89
|
await fs.writeFile(commentsPath, getJSON(comments));
|
|
72
|
-
// Send an alert to the manager
|
|
73
|
-
|
|
90
|
+
// Send an alert to the manager, including the resulting count, so a maintainer who has
|
|
91
|
+
// been away sees at a glance how urgently the comments need review.
|
|
92
|
+
await sendAlert(
|
|
93
|
+
'Kilotest: New AI tutorial comment received',
|
|
94
|
+
`A new AI tutorial comment has been received.\nComments now awaiting review: ${comments.length} of ${describeMax(commentsMax)}`
|
|
95
|
+
);
|
|
74
96
|
return {status: 'ok'};
|
|
75
97
|
};
|
|
@@ -100,6 +100,38 @@ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, a
|
|
|
100
100
|
assert.ok(comments[0].timeStamp);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test('handleComment returns an error with a fallback channel when the comments cap is reached', {timeout: 500}, async () => {
|
|
104
|
+
// Fill comments.json with 20 distinct comments, reaching the cap.
|
|
105
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
106
|
+
timeStamp: '260101T0000', content: `Filler comment number ${i}, long enough to pass the length check.`
|
|
107
|
+
}));
|
|
108
|
+
await fs.writeFile(getCommentsPath(), JSON.stringify(filler));
|
|
109
|
+
const result = await handleComment('One comment too many, long enough to pass the length check.');
|
|
110
|
+
assert.equal(result.status, 'error');
|
|
111
|
+
assert.ok(result.message?.startsWith('Too many comments are awaiting review right now.'));
|
|
112
|
+
assert.ok(result.message?.includes('https://github.com/jrpool/kilotest/issues'));
|
|
113
|
+
assert.ok(result.message?.includes('info@kilotest.com'));
|
|
114
|
+
// Clean up.
|
|
115
|
+
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('handleComment never rejects for a full inbox when TUTORIAL_AI_COMMENTS_MAX is 0 (no limit)', {timeout: 500}, async () => {
|
|
119
|
+
process.env.TUTORIAL_AI_COMMENTS_MAX = '0';
|
|
120
|
+
try {
|
|
121
|
+
// Fill comments.json past the default cap.
|
|
122
|
+
const filler = Array.from({length: 25}, (_, i) => ({
|
|
123
|
+
timeStamp: '260101T0000', content: `Filler comment number ${i}, long enough to pass the length check.`
|
|
124
|
+
}));
|
|
125
|
+
await fs.writeFile(getCommentsPath(), JSON.stringify(filler));
|
|
126
|
+
const result = await handleComment('One more comment, long enough to pass the length check.');
|
|
127
|
+
assert.equal(result.status, 'ok');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
delete process.env.TUTORIAL_AI_COMMENTS_MAX;
|
|
131
|
+
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
103
135
|
test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
|
|
104
136
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
105
137
|
const result = await handleComment('<img src=x onerror=alert(1)>\x00\x07Hello');
|
package/web/tutorialWeb/index.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {sendAlert} from '../../alerts.ts';
|
|
9
|
-
import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
|
|
9
|
+
import {checkCommentDuplicate, checkCommentLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
13
|
// FUNCTIONS (helpers)
|
|
14
14
|
|
|
15
15
|
const getCommentsPath = () => process.env.TUTORIAL_WEB_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialWeb.json');
|
|
16
|
+
// Maximum number of comments held at once. Once reached, a new, distinct comment is
|
|
17
|
+
// rejected rather than stored, so the comments file and the manager alert emails it
|
|
18
|
+
// generates cannot grow without bound (modeled on a full mailbox rejecting new mail; see
|
|
19
|
+
// GitHub issue #3, abuse type 3). Configurable via TUTORIAL_WEB_COMMENTS_MAX, since
|
|
20
|
+
// different Kilotest deployments' maintainers may want a different size; a value of 0
|
|
21
|
+
// means no limit.
|
|
22
|
+
const getCommentsMax = () => getEnvMax('TUTORIAL_WEB_COMMENTS_MAX', 20);
|
|
16
23
|
|
|
17
24
|
// FUNCTIONS
|
|
18
25
|
|
|
@@ -63,6 +70,17 @@ export const handleComment = async (content: unknown) => {
|
|
|
63
70
|
// Report this.
|
|
64
71
|
return duplicateCheck;
|
|
65
72
|
}
|
|
73
|
+
// If the comments already on file have reached the cap (a cap of 0 means no limit, so
|
|
74
|
+
// the comments are never full):
|
|
75
|
+
const commentsMax = getCommentsMax();
|
|
76
|
+
if (commentsMax > 0 && comments.length >= commentsMax) {
|
|
77
|
+
// Report this, with a fallback channel, since this comment cannot be stored here.
|
|
78
|
+
return {
|
|
79
|
+
status: 'error',
|
|
80
|
+
message: 'Too many comments are awaiting review right now. Please try again later, ' +
|
|
81
|
+
'or post your comment at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
66
84
|
// Add the comment to the existing ones.
|
|
67
85
|
comments.push({
|
|
68
86
|
timeStamp: getNowStamp(),
|
|
@@ -72,7 +90,11 @@ export const handleComment = async (content: unknown) => {
|
|
|
72
90
|
await fs.mkdir(path.dirname(commentsPath), {recursive: true});
|
|
73
91
|
// Save the revised comments.
|
|
74
92
|
await fs.writeFile(commentsPath, getJSON(comments));
|
|
75
|
-
// Send an alert to the manager
|
|
76
|
-
|
|
93
|
+
// Send an alert to the manager, including the resulting count, so a maintainer who has
|
|
94
|
+
// been away sees at a glance how urgently the comments need review.
|
|
95
|
+
await sendAlert(
|
|
96
|
+
'Kilotest: New web tutorial comment received',
|
|
97
|
+
`A new web tutorial comment has been received.\nComments now awaiting review: ${comments.length} of ${describeMax(commentsMax)}`
|
|
98
|
+
);
|
|
77
99
|
return {status: 'ok'};
|
|
78
100
|
};
|
|
@@ -103,6 +103,38 @@ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, a
|
|
|
103
103
|
assert.ok(comments[0].timeStamp);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test('handleComment returns an error with a fallback channel when the comments cap is reached', {timeout: 500}, async () => {
|
|
107
|
+
// Fill comments.json with 20 distinct comments, reaching the cap.
|
|
108
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
109
|
+
timeStamp: '260101T0000', content: `Filler comment number ${i}, long enough to pass the length check.`
|
|
110
|
+
}));
|
|
111
|
+
await fs.writeFile(getCommentsPath(), JSON.stringify(filler));
|
|
112
|
+
const result = await handleComment('One comment too many, long enough to pass the length check.');
|
|
113
|
+
assert.equal(result.status, 'error');
|
|
114
|
+
assert.ok(result.message?.startsWith('Too many comments are awaiting review right now.'));
|
|
115
|
+
assert.ok(result.message?.includes('https://github.com/jrpool/kilotest/issues'));
|
|
116
|
+
assert.ok(result.message?.includes('info@kilotest.com'));
|
|
117
|
+
// Clean up.
|
|
118
|
+
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('handleComment never rejects for a full inbox when TUTORIAL_WEB_COMMENTS_MAX is 0 (no limit)', {timeout: 500}, async () => {
|
|
122
|
+
process.env.TUTORIAL_WEB_COMMENTS_MAX = '0';
|
|
123
|
+
try {
|
|
124
|
+
// Fill comments.json past the default cap.
|
|
125
|
+
const filler = Array.from({length: 25}, (_, i) => ({
|
|
126
|
+
timeStamp: '260101T0000', content: `Filler comment number ${i}, long enough to pass the length check.`
|
|
127
|
+
}));
|
|
128
|
+
await fs.writeFile(getCommentsPath(), JSON.stringify(filler));
|
|
129
|
+
const result = await handleComment('One more comment, long enough to pass the length check.');
|
|
130
|
+
assert.equal(result.status, 'ok');
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
delete process.env.TUTORIAL_WEB_COMMENTS_MAX;
|
|
134
|
+
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
106
138
|
test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
|
|
107
139
|
await fs.writeFile(getCommentsPath(), '[]\n');
|
|
108
140
|
const result = await handleComment('<img src=x onerror=alert(1)>\x00\x07Hello');
|