@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/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
+
@@ -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
+ });
@@ -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
- await sendAlert('Kilotest: New AI tutorial comment received', 'A new AI tutorial comment has been received.');
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');
@@ -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
- await sendAlert('Kilotest: New web tutorial comment received', 'A new web tutorial comment has been received.');
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');