@jrpool/kilotest 49.0.0 → 51.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +100 -4
- package/api/requestFeature.ts +57 -13
- package/api/requestRetest.test.ts +46 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.test.ts +7 -0
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/BACKLOG.md +14 -2
- package/docs/SERVICE.md +50 -7
- package/index.test.ts +171 -7
- package/index.ts +103 -23
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +1 -1
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/util.test.ts +78 -13
- package/util.ts +35 -16
- package/web/ai0BalanceForm/index.ts +2 -2
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/enqueue/index.ts +33 -42
- package/web/hideReportForm/index.ts +4 -2
- package/web/manage/index.html +1 -0
- package/web/metrics/index.ts +3 -2
- package/web/reannotate/index.ts +2 -1
- package/web/renewWCAG/index.ts +2 -2
- package/web/reportDeletion.ts +3 -2
- package/web/showHiddenReportsForm/index.ts +2 -2
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/web/unhideReportForm/index.ts +4 -2
- package/.claude/scheduled_tasks.lock +0 -1
package/docs/SERVICE.md
CHANGED
|
@@ -64,7 +64,18 @@ pm2 start pm2.config.cjs
|
|
|
64
64
|
pm2 save
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
### PM2 log rotation
|
|
68
|
+
|
|
69
|
+
PM2's own log management is [documented by PM2](https://pm2.keymetrics.io/docs/usage/log-management/), but PM2 does not rotate its logs by default. Since the general request log (see “Request logging” above) writes one line per request rather than only for rejections, log volume is meaningfully higher than before that addition, so explicit rotation is configured via the [`pm2-logrotate`](https://github.com/keymetrics/pm2-logrotate) module:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
pm2 install pm2-logrotate
|
|
73
|
+
pm2 set pm2-logrotate:max_size 20M
|
|
74
|
+
pm2 set pm2-logrotate:retain 14
|
|
75
|
+
pm2 set pm2-logrotate:compress true
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This rotates `~/.pm2/logs/kilotest-out-0.log` (and the error log) once it reaches 20MB, keeps 14 rotated files, and compresses rotated files with gzip.
|
|
68
79
|
|
|
69
80
|
### Process tuning
|
|
70
81
|
|
|
@@ -233,13 +244,30 @@ The Caddy configuration is maintained and tracked in `/etc/caddy/Caddyfile`. Lea
|
|
|
233
244
|
# Docs: https://caddyserver.com/docs/caddyfile.
|
|
234
245
|
|
|
235
246
|
kilotest.com {
|
|
247
|
+
# Log every request in structured JSON, rotated so it cannot grow unbounded. See
|
|
248
|
+
# "Request logging" below for why this exists alongside Kilotest's own request log.
|
|
249
|
+
log {
|
|
250
|
+
output file /var/log/caddy/kilotest-access.log {
|
|
251
|
+
roll_size 50mb
|
|
252
|
+
roll_keep 10
|
|
253
|
+
roll_keep_for 720h
|
|
254
|
+
}
|
|
255
|
+
format json
|
|
256
|
+
}
|
|
236
257
|
# Enable Zstandard and Gzip compression of responses.
|
|
237
258
|
encode zstd gzip
|
|
238
|
-
# Specify the only paths of forwardable requests.
|
|
259
|
+
# Specify the only paths of forwardable requests.
|
|
239
260
|
@allowedGET {
|
|
240
|
-
method GET
|
|
261
|
+
method GET
|
|
241
262
|
path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt /capability.md *.html* /fullReport.json/* /api/* /tutorialWeb/images/* /tutorialAI/images/* /qai /qai/comments /favicon.* /style.css /sitemap.xml
|
|
242
263
|
}
|
|
264
|
+
# UptimeRobot's uptime check is the only known HEAD caller, and it targets only the site
|
|
265
|
+
# root (see "Health monitoring" below), so HEAD is forwarded only for that path, not the
|
|
266
|
+
# full GET allowlist above. index.ts's HEAD branch matches this same scope.
|
|
267
|
+
@allowedHEAD {
|
|
268
|
+
method HEAD
|
|
269
|
+
path / /index.html
|
|
270
|
+
}
|
|
243
271
|
@allowedPOST {
|
|
244
272
|
method POST
|
|
245
273
|
path /mcp /requestRetest.html/* /requestTest.html /requestAction.html /reannotate.html /renewWCAG.html /ai0BalanceForm.html /expungeReportsForm.html /hideReportForm.html /metrics.html /pruneReportsForm.html /rewindReportsForm.html /showHiddenReportsForm.html /unhideReportForm.html /api/* /tutorialWebComment.html /tutorialAIComment.html /worker/job /worker/report
|
|
@@ -254,13 +282,19 @@ kilotest.com {
|
|
|
254
282
|
}
|
|
255
283
|
respond 204
|
|
256
284
|
}
|
|
257
|
-
# Forward any other GET,
|
|
285
|
+
# Forward any other GET, HEAD, or POST request, if allowed, to port 3000.
|
|
258
286
|
handle @allowedGET {
|
|
259
287
|
reverse_proxy localhost:3000 {
|
|
260
288
|
# Improve SSE latency.
|
|
261
289
|
flush_interval -1
|
|
262
290
|
}
|
|
263
291
|
}
|
|
292
|
+
handle @allowedHEAD {
|
|
293
|
+
reverse_proxy localhost:3000 {
|
|
294
|
+
# Improve SSE latency.
|
|
295
|
+
flush_interval -1
|
|
296
|
+
}
|
|
297
|
+
}
|
|
264
298
|
handle @allowedPOST {
|
|
265
299
|
reverse_proxy localhost:3000 {
|
|
266
300
|
# Improve SSE latency.
|
|
@@ -278,6 +312,15 @@ This configuration can be futher tightened if experience warrants.
|
|
|
278
312
|
|
|
279
313
|
The `flush_interval` setting prevents granular reporting by Testaro workers from being buffered, so the updates reach the browser without delay.
|
|
280
314
|
|
|
315
|
+
### Request logging
|
|
316
|
+
|
|
317
|
+
Two independent request logs exist, added as part of the abuse evaluation in [GitHub issue #3](https://github.com/jrpool/kilotest/issues/3), so that suspicious traffic can be recognized after the fact rather than only the narrower set of cases that already trigger a rejection:
|
|
318
|
+
|
|
319
|
+
- **Application-level, general request log** (`index.ts`): every request Kilotest itself receives, whether it succeeds or is rejected, is logged as one single-line JSON record via `console.log` (captured by PM2 like all of Kilotest's other console output), with the fields `type: 'request'`, `time`, `ip`, `method`, `path` (no query string, so a parameter such as `authCode` is never written to a log file), `status`, and `userAgent`. This is separate from, and does not replace, `getAbuseError`'s existing multi-line dump, which remains a rarer, human-read-in-the-moment record logged only for rejected requests. Smoke-test-tagged requests (`x-kilotest-smoke`) are excluded from this log, exactly as they are already excluded from usage metrics; the maintainer's own manual testing is deliberately *not* excluded (unlike usage metrics), since abuse visibility should see all real traffic.
|
|
320
|
+
- **Caddy access log**: enabled at the reverse-proxy layer for connection- and pre-dispatch-level visibility the application itself never sees (for example, requests to paths outside Kilotest's own allowlist, which Caddy rejects before ever forwarding them). See the `log` directive in the Caddyfile above.
|
|
321
|
+
|
|
322
|
+
Both logs are rotated; see “PM2 log rotation” above for the application log and the `log` directive’s own `roll_size`/`roll_keep` options for the Caddy log.
|
|
323
|
+
|
|
281
324
|
## Version management
|
|
282
325
|
|
|
283
326
|
When a new version of the `kilotest` package has been published, the service can be updated as follows:
|
|
@@ -310,7 +353,7 @@ The smoke-test check is defined in `.github/workflows/smoke-test.yml` and runs `
|
|
|
310
353
|
When a pull request adds a new route to `index.js`, the Caddyfile at `/etc/caddy/Caddyfile` on the server must be updated to forward the new path before the pull request can merge. The workflow to follow is:
|
|
311
354
|
|
|
312
355
|
1. Add the new route to the `routes` constant and the dispatch chain in `index.js`.
|
|
313
|
-
1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path.
|
|
356
|
+
1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path, then reload Caddy so the change takes effect: `sudo systemctl reload caddy` (a reload, not a restart, so in-flight connections are not dropped).
|
|
314
357
|
1. Open or update the pull request.
|
|
315
358
|
1. The smoke test workflow runs and verifies that all paths, including the new one, are forwarded by Caddy.
|
|
316
359
|
1. If the Caddyfile was not updated, the smoke test reports a bare 404 for the new path, the required status check fails, and the pull request cannot merge.
|
|
@@ -332,7 +375,7 @@ The deployment is monitored for external health and availability using [UptimeRo
|
|
|
332
375
|
|
|
333
376
|
UptimeRobot also sends a recovery message when the service recovers and a subsequent check succeeds after a prior failure.
|
|
334
377
|
|
|
335
|
-
This monitoring service requires `Caddyfile` to permit `HEAD` requests, not only `GET` requests, to the root path.
|
|
378
|
+
This monitoring service requires both `Caddyfile` and Kilotest itself to permit `HEAD` requests, not only `GET` requests, to the root path: `Caddyfile`’s `@allowedHEAD` matcher forwards such a request to port 3000, and `index.ts`’s own dedicated `HEAD` branch in `handleRequest` answers it. Before that branch existed, Kilotest treated `HEAD` as an invalid method for every path (its dispatch recognized only `GET` and `POST`), so UptimeRobot’s hourly check received a `400` response and reported the service as down even while it was actually up.
|
|
336
379
|
|
|
337
380
|
## Alerting
|
|
338
381
|
|
|
@@ -356,7 +399,7 @@ Alerts are sent to the maintainer in the following conditions:
|
|
|
356
399
|
|
|
357
400
|
7. **Test request received via API**: When a test request is submitted via the API, an alert is sent with subject `"Kilotest: new test request in the API"` (via the `requestTest` API operation) or `"Kilotest: new retest request in the API"` (via the `requestRetest` API operation), including the target URL, description, and the user's stated reason. A request is treated as a duplicate, and no alert is sent for it, only if a request already pending in `db/jobs/testRequests.json`, i.e., submitted but not yet manually approved by the maintainer into an actual job, for the same URL has the identical description; this check ignores the stated reason and the test-versus-retest distinction, and is not limited to a recent time window, it compares against every request still pending approval for that URL. Once the maintainer approves a pending request (turning it into a job via `/enqueue.html`), all pending requests for that URL are cleared, so this check cannot detect a duplicate against a request that has already been approved, is currently running, or has already completed; an identical request submitted after approval alerts again. The check and its dedup key are identical for the UI and API paths, since both funnel through the same underlying request-queuing function.
|
|
358
401
|
|
|
359
|
-
8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request.
|
|
402
|
+
8. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request, and the request is also appended to `db/featureRequests.json`, the same way tutorial comments are appended to their own files (see item 1), so that a manager can review or delete it later via `/deleteNotesForm.html`.
|
|
360
403
|
|
|
361
404
|
9. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
|
|
362
405
|
|
package/index.test.ts
CHANGED
|
@@ -15,9 +15,19 @@ process.env.DB_DIR = fixtureDBDir;
|
|
|
15
15
|
process.env.AUTH_CODE = 'test-auth-code';
|
|
16
16
|
process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
|
|
17
17
|
process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
|
|
18
|
+
process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.json');
|
|
18
19
|
process.env.TESTARO_WORKERS = JSON.stringify({
|
|
19
20
|
worker1: {secret: 'secret1', name: 'Worker One'}
|
|
20
21
|
});
|
|
22
|
+
// Blank the alert configuration unconditionally, so this file sends no real alert
|
|
23
|
+
// emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
|
|
24
|
+
// when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
|
|
25
|
+
// which normally guards against this by --require-ing test/setup.ts first. dotenv does
|
|
26
|
+
// not override an already-set env var, so setting these here, before index.ts is ever
|
|
27
|
+
// imported, prevents a real .env file's alert credentials from ever taking effect.
|
|
28
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
29
|
+
process.env[key] = '';
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
// IMPORTS
|
|
23
33
|
|
|
@@ -58,6 +68,7 @@ beforeEach(async () => {
|
|
|
58
68
|
await fs.mkdir(testCommentsDir, {recursive: true});
|
|
59
69
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
|
|
60
70
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
|
|
71
|
+
await fs.writeFile(path.join(testCommentsDir, 'featureRequests.json'), '[]\n');
|
|
61
72
|
});
|
|
62
73
|
|
|
63
74
|
after(async () => {
|
|
@@ -215,6 +226,30 @@ test('GET /index.html serves the home page', async () => {
|
|
|
215
226
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
216
227
|
});
|
|
217
228
|
|
|
229
|
+
// TESTS: HEAD routes
|
|
230
|
+
|
|
231
|
+
test('HEAD / returns the same status and headers as GET /, with no body', async () => {
|
|
232
|
+
const getRes = await request('GET', '/');
|
|
233
|
+
const headRes = await request('HEAD', '/');
|
|
234
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
235
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
236
|
+
assert.equal(headRes.body, '');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('HEAD /index.html returns the same status and headers as GET /, with no body', async () => {
|
|
240
|
+
const getRes = await request('GET', '/');
|
|
241
|
+
const headRes = await request('HEAD', '/index.html');
|
|
242
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
243
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
244
|
+
assert.equal(headRes.body, '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('HEAD /robots.txt returns an invalid HEAD request error, since only the home page supports HEAD', async () => {
|
|
248
|
+
const res = await request('HEAD', '/robots.txt');
|
|
249
|
+
assert.equal(res.statusCode, 400);
|
|
250
|
+
assert.equal(res.body, '');
|
|
251
|
+
});
|
|
252
|
+
|
|
218
253
|
test('GET /robots.txt serves the robots file as text', async () => {
|
|
219
254
|
const res = await request('GET', '/robots.txt');
|
|
220
255
|
assert.equal(res.statusCode, 200);
|
|
@@ -826,6 +861,40 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
|
|
|
826
861
|
assert.equal(res.headers['location'], '/tutorialAI.html');
|
|
827
862
|
});
|
|
828
863
|
|
|
864
|
+
test('GET /diagnoses.html/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
|
|
865
|
+
const res = await request(
|
|
866
|
+
'GET', '/diagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body', {}, {followRedirects: false}
|
|
867
|
+
);
|
|
868
|
+
assert.equal(res.statusCode, 301);
|
|
869
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body');
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test('GET /reportIssues.html/<timeStamp>/<jobID> redirects to the equivalent /listIssues.html/<timeStamp>/<jobID>', async () => {
|
|
873
|
+
const res = await request('GET', '/reportIssues.html/260101T0000/mix', {}, {followRedirects: false});
|
|
874
|
+
assert.equal(res.statusCode, 301);
|
|
875
|
+
assert.equal(res.headers['location'], '/listIssues.html/260101T0000/mix');
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('GET /reportIssue.html/<segments> redirects to the equivalent /listViolators.html/<segments>', async () => {
|
|
879
|
+
const res = await request(
|
|
880
|
+
'GET', '/reportIssue.html/duplicateID/260101T0000/mix', {}, {followRedirects: false}
|
|
881
|
+
);
|
|
882
|
+
assert.equal(res.statusCode, 301);
|
|
883
|
+
assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000/mix');
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('GET /rules.html/<segment> redirects to the equivalent /listRules.html/<segment>', async () => {
|
|
887
|
+
const res = await request('GET', '/rules.html/allCaps', {}, {followRedirects: false});
|
|
888
|
+
assert.equal(res.statusCode, 301);
|
|
889
|
+
assert.equal(res.headers['location'], '/listRules.html/allCaps');
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('GET /targets.html redirects to /listReports.html', async () => {
|
|
893
|
+
const res = await request('GET', '/targets.html', {}, {followRedirects: false});
|
|
894
|
+
assert.equal(res.statusCode, 301);
|
|
895
|
+
assert.equal(res.headers['location'], '/listReports.html');
|
|
896
|
+
});
|
|
897
|
+
|
|
829
898
|
test('POST /requestTest.html with non-https URL returns an error', async () => {
|
|
830
899
|
const res = await formRequest('POST', '/requestTest.html', {
|
|
831
900
|
description: 'Test Page',
|
|
@@ -1213,6 +1282,7 @@ const htmlPagePaths = [
|
|
|
1213
1282
|
'/pruneReportsForm.html',
|
|
1214
1283
|
'/rewindReportsForm.html',
|
|
1215
1284
|
'/ai0BalanceForm.html',
|
|
1285
|
+
'/deleteNotesForm.html',
|
|
1216
1286
|
'/metrics.html'
|
|
1217
1287
|
];
|
|
1218
1288
|
|
|
@@ -1390,6 +1460,32 @@ test('POST /rewindReportsForm.html with no selections shows the form and records
|
|
|
1390
1460
|
assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
|
|
1391
1461
|
});
|
|
1392
1462
|
|
|
1463
|
+
test('POST /deleteNotesForm.html deletes multiple selected notes across sources in one submission', async () => {
|
|
1464
|
+
await fs.writeFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, JSON.stringify([
|
|
1465
|
+
{timeStamp: '260101T0000', content: 'A web tutorial comment'}
|
|
1466
|
+
]));
|
|
1467
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify([
|
|
1468
|
+
{timeStamp: '260101T0001', content: 'A feature request'},
|
|
1469
|
+
{timeStamp: '260101T0001', content: 'A different feature request'}
|
|
1470
|
+
]));
|
|
1471
|
+
// Reconstructing the POST body's query string uses querystring.stringify, which (unlike
|
|
1472
|
+
// the URLSearchParams object constructor) preserves multiple values for the same field
|
|
1473
|
+
// name as repeated key=value pairs rather than collapsing them into one comma-joined
|
|
1474
|
+
// value, so this exercises that multi-checkbox submission actually deletes every
|
|
1475
|
+
// selected note, not just the one whose value happens to survive the collapse.
|
|
1476
|
+
const res = await formRequest('POST', '/deleteNotesForm.html', [
|
|
1477
|
+
['authCode', 'test-auth-code'],
|
|
1478
|
+
['note', `tutorialWeb\t${encodeURIComponent('A web tutorial comment')}`],
|
|
1479
|
+
['note', `featureRequest\t${encodeURIComponent('A feature request')}`]
|
|
1480
|
+
]);
|
|
1481
|
+
assert.equal(res.statusCode, 200);
|
|
1482
|
+
const webComments = JSON.parse(await fs.readFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, 'utf8'));
|
|
1483
|
+
assert.equal(webComments.length, 0);
|
|
1484
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
1485
|
+
assert.equal(featureRequests.length, 1);
|
|
1486
|
+
assert.equal(featureRequests[0].content, 'A different feature request');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1393
1489
|
test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
|
|
1394
1490
|
const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
|
|
1395
1491
|
const backup = await fs.readFile(reportPath, 'utf8');
|
|
@@ -1545,7 +1641,7 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
|
|
|
1545
1641
|
assert.ok(res.body.includes('Test request duplicates an already submitted request'));
|
|
1546
1642
|
});
|
|
1547
1643
|
|
|
1548
|
-
test('POST /requestAction.html with valid auth code
|
|
1644
|
+
test('POST /requestAction.html with valid auth code approves the same target twice without error', {timeout: 500}, async () => {
|
|
1549
1645
|
await fs.writeFile(testRequestsPath, '{}\n');
|
|
1550
1646
|
// Create a request.
|
|
1551
1647
|
await formRequest('POST', '/requestTest.html', {
|
|
@@ -1559,20 +1655,19 @@ test('POST /requestAction.html with valid auth code and approval of a duplicate
|
|
|
1559
1655
|
authCode: 'test-auth-code',
|
|
1560
1656
|
what: 'yes'
|
|
1561
1657
|
});
|
|
1562
|
-
// Approve it again
|
|
1563
|
-
//
|
|
1658
|
+
// Approve it again. enqueue.answer has no duplicate-detection logic of its own (that
|
|
1659
|
+
// lives in processTestRequest, upstream of approval), so a second approval of the same
|
|
1660
|
+
// target queues a second job rather than erroring.
|
|
1564
1661
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1565
1662
|
target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
|
|
1566
1663
|
authCode: 'test-auth-code',
|
|
1567
1664
|
what: 'yes'
|
|
1568
1665
|
});
|
|
1569
|
-
|
|
1570
|
-
assert.ok(res.statusCode === 200 || res.statusCode === 400);
|
|
1666
|
+
assert.equal(res.statusCode, 200);
|
|
1571
1667
|
});
|
|
1572
1668
|
|
|
1573
1669
|
test('POST /requestAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
|
|
1574
|
-
// A URL that starts with https:// but is not a valid URL
|
|
1575
|
-
// enqueue.answer to return status error.
|
|
1670
|
+
// A URL that starts with https:// but is not a valid URL fails index.ts's own isURL check.
|
|
1576
1671
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1577
1672
|
target: 'https://\tTest Page',
|
|
1578
1673
|
authCode: 'test-auth-code',
|
|
@@ -1646,6 +1741,75 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
|
|
|
1646
1741
|
assert.equal(result.reason, 'test reason');
|
|
1647
1742
|
});
|
|
1648
1743
|
|
|
1744
|
+
// TESTS FOR general per-request logging
|
|
1745
|
+
|
|
1746
|
+
test('a request logs a single-line JSON record with the expected fields', async () => {
|
|
1747
|
+
const logged: string[] = [];
|
|
1748
|
+
const originalLog = console.log;
|
|
1749
|
+
console.log = (line: string) => logged.push(line);
|
|
1750
|
+
try {
|
|
1751
|
+
await request('GET', '/listReports.html');
|
|
1752
|
+
}
|
|
1753
|
+
finally {
|
|
1754
|
+
console.log = originalLog;
|
|
1755
|
+
}
|
|
1756
|
+
const requestLines = logged.filter(line => {
|
|
1757
|
+
try {
|
|
1758
|
+
return JSON.parse(line).type === 'request';
|
|
1759
|
+
}
|
|
1760
|
+
catch {
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
assert.equal(requestLines.length, 1);
|
|
1765
|
+
const record = JSON.parse(requestLines[0]!);
|
|
1766
|
+
assert.equal(record.method, 'GET');
|
|
1767
|
+
assert.equal(record.path, '/listReports.html');
|
|
1768
|
+
assert.equal(record.status, 200);
|
|
1769
|
+
assert.ok(record.ip);
|
|
1770
|
+
assert.ok(record.userAgent);
|
|
1771
|
+
assert.ok(record.time);
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
test('a request logs its path without its query string', async () => {
|
|
1775
|
+
const logged: string[] = [];
|
|
1776
|
+
const originalLog = console.log;
|
|
1777
|
+
console.log = (line: string) => logged.push(line);
|
|
1778
|
+
try {
|
|
1779
|
+
await request('GET', '/listIssues.html/260101T0000/mix?authCode=secret');
|
|
1780
|
+
}
|
|
1781
|
+
finally {
|
|
1782
|
+
console.log = originalLog;
|
|
1783
|
+
}
|
|
1784
|
+
const record = logged
|
|
1785
|
+
.map(line => { try {return JSON.parse(line);} catch {return null;} })
|
|
1786
|
+
.find(parsed => parsed?.type === 'request');
|
|
1787
|
+
assert.ok(record);
|
|
1788
|
+
assert.equal(record.path, '/listIssues.html/260101T0000/mix');
|
|
1789
|
+
assert.ok(!JSON.stringify(record).includes('secret'));
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
test('a smoke-test-flagged request is not logged as a general request', async () => {
|
|
1793
|
+
const logged: string[] = [];
|
|
1794
|
+
const originalLog = console.log;
|
|
1795
|
+
console.log = (line: string) => logged.push(line);
|
|
1796
|
+
try {
|
|
1797
|
+
await request('GET', '/listReports.html', null, {'x-kilotest-smoke': '1'});
|
|
1798
|
+
}
|
|
1799
|
+
finally {
|
|
1800
|
+
console.log = originalLog;
|
|
1801
|
+
}
|
|
1802
|
+
const requestLines = logged.filter(line => {
|
|
1803
|
+
try {
|
|
1804
|
+
return JSON.parse(line).type === 'request';
|
|
1805
|
+
}
|
|
1806
|
+
catch {
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
assert.equal(requestLines.length, 0);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1649
1813
|
// UNIT TESTS FOR startServer
|
|
1650
1814
|
|
|
1651
1815
|
test('startServer starts an HTTP server when protocol is http', {timeout: 500}, async () => {
|
package/index.ts
CHANGED
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
isReportAvailable,
|
|
24
24
|
isTimeStamp,
|
|
25
25
|
isJobID,
|
|
26
|
+
isURL,
|
|
26
27
|
isUsableReport,
|
|
28
|
+
isValidAuthCode,
|
|
27
29
|
jobsPath,
|
|
28
30
|
metricsExclusionCookieName,
|
|
29
31
|
recordMetric,
|
|
@@ -40,7 +42,9 @@ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './
|
|
|
40
42
|
import http, {type IncomingMessage, type ServerResponse} from 'node:http';
|
|
41
43
|
import https from 'node:https';
|
|
42
44
|
import path from 'node:path';
|
|
45
|
+
import querystring from 'node:querystring';
|
|
43
46
|
import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
|
|
47
|
+
import {answer as deleteNotesForm} from './web/deleteNotesForm/index.ts';
|
|
44
48
|
import {answer as enqueue} from './web/enqueue/index.ts';
|
|
45
49
|
import {answer as enqueueForm} from './web/enqueueForm/index.ts';
|
|
46
50
|
import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
|
|
@@ -95,6 +99,7 @@ type PageHandler = (...args: any[]) => Promise<AnswerData>;
|
|
|
95
99
|
|
|
96
100
|
const answer: {
|
|
97
101
|
ai0BalanceForm: PageHandler;
|
|
102
|
+
deleteNotesForm: PageHandler;
|
|
98
103
|
enqueue: PageHandler;
|
|
99
104
|
enqueueForm: PageHandler;
|
|
100
105
|
expungeReportsForm: PageHandler;
|
|
@@ -124,6 +129,7 @@ const answer: {
|
|
|
124
129
|
[key: string]: PageHandler | undefined;
|
|
125
130
|
} = {
|
|
126
131
|
ai0BalanceForm,
|
|
132
|
+
deleteNotesForm,
|
|
127
133
|
enqueue,
|
|
128
134
|
enqueueForm,
|
|
129
135
|
expungeReportsForm,
|
|
@@ -205,6 +211,7 @@ export const routes = {
|
|
|
205
211
|
'/api/*',
|
|
206
212
|
'/mcp',
|
|
207
213
|
'/ai0BalanceForm.html',
|
|
214
|
+
'/deleteNotesForm.html',
|
|
208
215
|
'/expungeReportsForm.html',
|
|
209
216
|
'/hideReportForm.html',
|
|
210
217
|
'/metrics.html',
|
|
@@ -238,6 +245,7 @@ const managerPages = new Set([
|
|
|
238
245
|
'unhideReportForm',
|
|
239
246
|
'ai0BalanceForm',
|
|
240
247
|
'renewWCAGForm',
|
|
248
|
+
'deleteNotesForm',
|
|
241
249
|
'metrics'
|
|
242
250
|
]);
|
|
243
251
|
// The set of manager pages (a subset of managerPages) whose single answer() function both
|
|
@@ -253,6 +261,7 @@ const selfSubmittingManagerPages = new Set([
|
|
|
253
261
|
'showHiddenReportsForm',
|
|
254
262
|
'unhideReportForm',
|
|
255
263
|
'ai0BalanceForm',
|
|
264
|
+
'deleteNotesForm',
|
|
256
265
|
'metrics'
|
|
257
266
|
]);
|
|
258
267
|
// The subset of selfSubmittingManagerPages that must never be served on a direct GET
|
|
@@ -260,6 +269,17 @@ const selfSubmittingManagerPages = new Set([
|
|
|
260
269
|
// unhideReportForm is served only by showHiddenReportsForm, once a valid authorization code
|
|
261
270
|
// has been submitted), so that the names of hidden reports are never disclosed without one.
|
|
262
271
|
const noDirectGetPages = new Set(['unhideReportForm']);
|
|
272
|
+
// Old page names, from before a 2026-08-31 rename (see GitHub issue #3), that search
|
|
273
|
+
// engines and other crawlers are still requesting. Redirecting them lets crawlers update
|
|
274
|
+
// their own indexes, rather than leaving them to repeat the same dead request indefinitely.
|
|
275
|
+
// TEMPORARY: retire this redirect after 2027-04-01 once crawlers have re-indexed.
|
|
276
|
+
const renamedPagePrefixes: Record<string, string> = {
|
|
277
|
+
diagnoses: 'listDiagnoses',
|
|
278
|
+
reportIssue: 'listViolators',
|
|
279
|
+
reportIssues: 'listIssues',
|
|
280
|
+
rules: 'listRules',
|
|
281
|
+
targets: 'listReports'
|
|
282
|
+
};
|
|
263
283
|
const jobLock = createLock();
|
|
264
284
|
|
|
265
285
|
// FUNCTIONS
|
|
@@ -309,16 +329,22 @@ export const serveError = async (error: Record<string, unknown>, response: Serve
|
|
|
309
329
|
console.log('Cannot send error response because the response has ended.');
|
|
310
330
|
}
|
|
311
331
|
};
|
|
332
|
+
// Returns the IP address a request should be attributed to: the value of its
|
|
333
|
+
// x-forwarded-for header (Caddy runs in front of Kilotest, so a real client's address
|
|
334
|
+
// arrives this way, not as the socket's own remote address), else the socket's remote
|
|
335
|
+
// address, else 'unknown' if neither is available.
|
|
336
|
+
export const getRequestIP = (request: IncomingMessage): string | string[] => {
|
|
337
|
+
const forwardedFor = request.headers['x-forwarded-for'];
|
|
338
|
+
const remoteAddress = request.socket.remoteAddress;
|
|
339
|
+
return forwardedFor || remoteAddress || 'unknown';
|
|
340
|
+
};
|
|
312
341
|
// Creates an error object about a suspicious request.
|
|
313
342
|
export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
|
|
314
343
|
const {method, url, headers} = request;
|
|
315
|
-
const forwardedFor = headers['x-forwarded-for'];
|
|
316
|
-
const remoteAddress = request.socket.remoteAddress;
|
|
317
|
-
const ip = forwardedFor || remoteAddress || 'unknown';
|
|
318
344
|
return {
|
|
319
345
|
message: 'Invalid request',
|
|
320
346
|
reason,
|
|
321
|
-
'IP address':
|
|
347
|
+
'IP address': getRequestIP(request),
|
|
322
348
|
method,
|
|
323
349
|
URL: url,
|
|
324
350
|
'user agent': headers['user-agent'] || 'none',
|
|
@@ -326,6 +352,39 @@ export const getAbuseError = (request: IncomingMessage, reason: string | undefin
|
|
|
326
352
|
time: new Date().toISOString()
|
|
327
353
|
};
|
|
328
354
|
};
|
|
355
|
+
// Logs one line for every request, successful or not, once it finishes. Unlike
|
|
356
|
+
// getAbuseError (logged only for rejected requests, as a rare, human-read-in-the-moment
|
|
357
|
+
// dump), this is a terse, single-line-per-request JSON record, intended to be high-volume
|
|
358
|
+
// and machine-parseable, so that any kind of suspicious traffic pattern (not only the
|
|
359
|
+
// specific rejection reasons getAbuseError already covers) can be recognized later. It
|
|
360
|
+
// deliberately does not consult isMetricsExcluded: that exclusion exists to keep the
|
|
361
|
+
// maintainer's own manual testing out of usage counts, but abuse visibility should see
|
|
362
|
+
// all traffic, including the maintainer's own, or it recreates the same kind of blind
|
|
363
|
+
// spot this logging exists to close.
|
|
364
|
+
const logRequestOnFinish = (request: IncomingMessage, response: ServerResponse): void => {
|
|
365
|
+
const {method, headers} = request;
|
|
366
|
+
const ip = getRequestIP(request);
|
|
367
|
+
const userAgent = headers['user-agent'] || 'none';
|
|
368
|
+
const path = new URL(request.url as string, 'https://localhost:3000').pathname;
|
|
369
|
+
const originalEnd = response.end.bind(response);
|
|
370
|
+
// Reassign response.end so that every path through handleRequest logs exactly once,
|
|
371
|
+
// right before the response is actually sent, rather than requiring every dispatch
|
|
372
|
+
// branch to remember to log itself (the same class of gap that left successful
|
|
373
|
+
// requests unlogged in the first place). No query string is logged, so that a
|
|
374
|
+
// parameter such as authCode is never written to a log file.
|
|
375
|
+
response.end = ((...args: Parameters<typeof originalEnd>) => {
|
|
376
|
+
console.log(JSON.stringify({
|
|
377
|
+
type: 'request',
|
|
378
|
+
time: new Date().toISOString(),
|
|
379
|
+
ip,
|
|
380
|
+
method,
|
|
381
|
+
path,
|
|
382
|
+
status: response.statusCode,
|
|
383
|
+
userAgent
|
|
384
|
+
}));
|
|
385
|
+
return originalEnd(...args);
|
|
386
|
+
}) as typeof response.end;
|
|
387
|
+
};
|
|
329
388
|
// Returns whether a request carries the metrics-exclusion cookie with the value that
|
|
330
389
|
// proves it. A request without a valid AUTH_CODE configured can never match, since
|
|
331
390
|
// getExclusionCookieValue then hashes an empty string, which no cookie should equal.
|
|
@@ -487,6 +546,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
487
546
|
response.end('{}');
|
|
488
547
|
return;
|
|
489
548
|
}
|
|
549
|
+
// Log this request once it finishes, for general abuse/usage visibility (see GitHub
|
|
550
|
+
// issue #3). Placed after the smoke-test short-circuit above, so CI's own smoke-test
|
|
551
|
+
// traffic is excluded from this log exactly as it is already excluded from metrics.
|
|
552
|
+
logRequestOnFinish(request, response);
|
|
490
553
|
// Records a pageViews or apiOperations metric, unless this request's browser has proven,
|
|
491
554
|
// via a valid authCode submitted earlier on metrics.html, that it belongs to the
|
|
492
555
|
// maintainer testing Kilotest manually rather than to real usage.
|
|
@@ -636,13 +699,21 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
636
699
|
// Otherwise, if it is for an HTML page other than the home page:
|
|
637
700
|
else if (pageName.endsWith('.html')) {
|
|
638
701
|
const topic = pageName.slice(0, -5);
|
|
639
|
-
// If
|
|
702
|
+
// If it is for a page's old, pre-rename name:
|
|
703
|
+
if (renamedPagePrefixes[topic]) {
|
|
704
|
+
// Redirect the client permanently to the current page name, preserving any
|
|
705
|
+
// further path segments and the query string.
|
|
706
|
+
const newLocation = `/${renamedPagePrefixes[topic]}.html${pathTail ? `/${pathTail}` : ''}${search}`;
|
|
707
|
+
response.writeHead(301, {Location: newLocation});
|
|
708
|
+
response.end();
|
|
709
|
+
}
|
|
710
|
+
// Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
|
|
640
711
|
// the '*.html*' GET pattern, but its handler expects POST's argument list and
|
|
641
712
|
// performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
|
|
642
713
|
// too, but its handler serves both methods the same way, so it is not excluded here,
|
|
643
714
|
// except for a page in noDirectGetPages, which stays excluded from GET even though
|
|
644
715
|
// it is self-submitting, since it must be reachable only via another page's POST):
|
|
645
|
-
if (
|
|
716
|
+
else if (
|
|
646
717
|
answer[topic] &&
|
|
647
718
|
!noDirectGetPages.has(topic) &&
|
|
648
719
|
(!isPathAllowed('POST', pathname) || selfSubmittingManagerPages.has(topic))
|
|
@@ -793,6 +864,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
793
864
|
);
|
|
794
865
|
}
|
|
795
866
|
}
|
|
867
|
+
// Otherwise, if the request is a HEAD request: only the home page supports HEAD, since
|
|
868
|
+
// UptimeRobot's uptime check is the only known HEAD caller and it targets only that path
|
|
869
|
+
// (see "Health monitoring" in docs/SERVICE.md). Its response must match what GET / would
|
|
870
|
+
// send, minus the body, so that a monitor reading headers alone sees the real page state.
|
|
871
|
+
else if (method === 'HEAD') {
|
|
872
|
+
if (['/', '/index.html'].includes(pathname)) {
|
|
873
|
+
setHeaders('text/html', '/index.html', 'medium');
|
|
874
|
+
response.end();
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
await serveError({message: `ERROR: Invalid HEAD request (${pathname})`}, response, true);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
796
880
|
// Otherwise, if the request is a POST request:
|
|
797
881
|
else if (method === 'POST') {
|
|
798
882
|
// If the path is not authorized for POST requests:
|
|
@@ -815,7 +899,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
815
899
|
else if (pageName === 'requestTest.html') {
|
|
816
900
|
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
817
901
|
// If the request is valid:
|
|
818
|
-
if (description && url
|
|
902
|
+
if (description && isURL(url) && why) {
|
|
819
903
|
// If a report on the page is already available:
|
|
820
904
|
if (await isReportAvailable(description, url)) {
|
|
821
905
|
// Report the error.
|
|
@@ -877,7 +961,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
877
961
|
const {target, authCode} = postData as {target: string; authCode?: string};
|
|
878
962
|
const [url, description] = target.split('\t') as [string, string];
|
|
879
963
|
// If the request is valid:
|
|
880
|
-
if (url
|
|
964
|
+
if (isURL(url) && isValidAuthCode(authCode)) {
|
|
881
965
|
// Set the non-location headers for a response.
|
|
882
966
|
setHeaders('text/html', null, 'ultra');
|
|
883
967
|
// If the request is an approval:
|
|
@@ -885,19 +969,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
885
969
|
// Set a location header for a response.
|
|
886
970
|
response.setHeader('content-location', pathname);
|
|
887
971
|
// Process the approval and get the answer data about the remaining requests.
|
|
888
|
-
const answerData = await answer.enqueue(url, description
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
// Serve the test-order page with the remaining recommendations.
|
|
893
|
-
response.end(answerData.answerPage);
|
|
894
|
-
}
|
|
895
|
-
// Otherwise, i.e. if they are invalid:
|
|
896
|
-
else {
|
|
897
|
-
await recordMetric('managerActivity', 'requestAction.html', 'error');
|
|
898
|
-
// Report the error.
|
|
899
|
-
await serveError({message: answerData.message}, response, true);
|
|
900
|
-
}
|
|
972
|
+
const answerData = await answer.enqueue(url, description);
|
|
973
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
974
|
+
// Serve the test-order page with the remaining recommendations.
|
|
975
|
+
response.end(answerData.answerPage);
|
|
901
976
|
}
|
|
902
977
|
// Otherwise, i.e. if it is a rejection:
|
|
903
978
|
else {
|
|
@@ -968,7 +1043,12 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
968
1043
|
setHeaders('text/html', pathname, 'ultra');
|
|
969
1044
|
// Reconstruct a query string from the POST body, so the page's answer() function
|
|
970
1045
|
// can read its submitted parameters the same way it reads a GET query string.
|
|
971
|
-
|
|
1046
|
+
// querystring.stringify (rather than the URLSearchParams object constructor) is
|
|
1047
|
+
// required here, because postData, from querystring.parse in getPOSTData, holds
|
|
1048
|
+
// repeated form fields (e.g. multiple checked checkboxes sharing a name) as an
|
|
1049
|
+
// array; the URLSearchParams constructor would join such an array into a single
|
|
1050
|
+
// comma-separated value instead of preserving it as repeated key=value pairs.
|
|
1051
|
+
const search = `?${querystring.stringify(postData as Record<string, string | string[]>)}`;
|
|
972
1052
|
// Get the answer data. The method is passed so the handler only processes a
|
|
973
1053
|
// submission for POST, never for GET, regardless of what its query string contains.
|
|
974
1054
|
const answerData = await answer[topic]!(pathTail, search, method);
|
|
@@ -1159,7 +1239,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1159
1239
|
}
|
|
1160
1240
|
}
|
|
1161
1241
|
}
|
|
1162
|
-
// Otherwise, i.e. if it is
|
|
1242
|
+
// Otherwise, i.e. if it is none of GET, HEAD, or POST:
|
|
1163
1243
|
else {
|
|
1164
1244
|
// Report its invalidity. (Caddy handles OPTIONS requests.)
|
|
1165
1245
|
await serveError({message: 'ERROR: Invalid request method'}, response, true);
|
package/linkValidation.test.ts
CHANGED
package/mcp.test.ts
CHANGED
|
@@ -157,7 +157,7 @@ test('requestFeature handler returns content and structuredContent', async () =>
|
|
|
157
157
|
const server = createMCPServer();
|
|
158
158
|
const countBefore = getToolCallCount('requestFeature');
|
|
159
159
|
const result = await (server as any)._registeredTools.requestFeature.handler({
|
|
160
|
-
feature: 'A new feature idea'
|
|
160
|
+
feature: 'A new feature idea worth considering'
|
|
161
161
|
});
|
|
162
162
|
assert.ok(result.content);
|
|
163
163
|
assert.equal(result.content[0].type, 'text');
|