@jrpool/kilotest 55.0.0 → 57.0.1
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/README.md +1 -1
- package/api/awaitTest.test.ts +114 -0
- package/api/awaitTest.ts +108 -0
- package/api/listReports.ts +2 -2
- package/api/orderNewTest.test.ts +213 -0
- package/api/orderNewTest.ts +145 -0
- package/api/orderRetest.test.ts +179 -0
- package/api/orderRetest.ts +119 -0
- package/api/{requestTest.test.ts → requestNewTest.test.ts} +25 -25
- package/api/{requestTest.ts → requestNewTest.ts} +16 -39
- package/api/requestRetest.test.ts +6 -6
- package/api/requestRetest.ts +10 -33
- package/api/routes.test.ts +7 -4
- package/api/routes.ts +45 -7
- package/api/schemas.ts +120 -10
- package/api/util.ts +59 -2
- package/api/version.ts +6 -2
- package/capability.md +8 -5
- package/docs/SERVICE.md +6 -8
- package/env.example +15 -0
- package/index.test.ts +95 -24
- package/index.ts +80 -16
- package/lhm.plugin.json +1 -1
- package/linkValidation.test.ts +2 -2
- package/llms-full.txt +34 -3
- package/llms.txt +5 -2
- package/mcp.test.ts +72 -12
- package/mcp.ts +81 -11
- package/memory/MEMORY.md +1 -0
- package/memory/plan_issue_111_instant_test.md +198 -0
- package/openapi.yaml +451 -4
- package/package.json +1 -1
- package/routes.test.ts +1 -1
- package/routing.test.ts +2 -2
- package/server.json +1 -1
- package/sitemap.xml +1 -1
- package/smokeTest.ts +2 -2
- package/util.test.ts +214 -2
- package/util.ts +175 -7
- package/web/listReports/index.html +1 -1
- package/web/listReports/listReports.test.ts +1 -1
- package/web/{requestTest → requestNewTest}/index.ts +1 -1
- package/web/{requestTest/requestTest.test.ts → requestNewTest/requestNewTest.test.ts} +2 -2
- package/web/{requestTestForm → requestNewTestForm}/index.html +1 -1
- package/web/{requestTestForm/requestTestForm.test.ts → requestNewTestForm/requestNewTestForm.test.ts} +6 -6
- /package/web/{requestTest → requestNewTest}/index.html +0 -0
- /package/web/{requestTestForm → requestNewTestForm}/index.ts +0 -0
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ In the steps below, hosts `T` and `K` may be the same host or two different host
|
|
|
40
40
|
|
|
41
41
|
### Usage
|
|
42
42
|
|
|
43
|
-
1. In the `testaro` directory, make Testaro start listening for jobs: `node call netWatch true nn true`, where `nn` is the number of seconds to wait between checks for new jobs.
|
|
43
|
+
1. In the `testaro` directory, make Testaro start listening for jobs: `node call netWatch true nn true`, where `nn` is the number of seconds to wait between checks for new jobs. Note that Kilotest promises nearly immediate review and, on approval, immediate execution of “orders” for tests and retests, with typical total elapsed time about 2 minutes. That promise implies a promise to keep `nn` small. A reasonable value is between 10 and 20.
|
|
44
44
|
1. In the `kilotest` directory, start the Kilotest service: `node index`.
|
|
45
45
|
|
|
46
46
|
### Contributing
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
awaitTest.test.ts
|
|
3
|
+
Tests for api/awaitTest.ts using the fixture corpus.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {test, before, after} from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {fixtureDBDir} from '../test/dbFixture.ts';
|
|
13
|
+
|
|
14
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
15
|
+
process.env[key] = '';
|
|
16
|
+
}
|
|
17
|
+
// Keep the poll fast in this file, since these tests do not exercise timing edge cases
|
|
18
|
+
// (those live in util.test.ts, closer to awaitJob itself); a short interval just keeps
|
|
19
|
+
// this file's own tests quick.
|
|
20
|
+
process.env.AWAIT_JOB_POLL_MS = '10';
|
|
21
|
+
process.env.AWAIT_JOB_TIMEOUT_MS = '50';
|
|
22
|
+
|
|
23
|
+
// SETUP AND TEARDOWN
|
|
24
|
+
|
|
25
|
+
const savedDBDir = process.env.DB_DIR;
|
|
26
|
+
|
|
27
|
+
before(() => {
|
|
28
|
+
process.env.DB_DIR = fixtureDBDir;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import {response} from './awaitTest.ts';
|
|
32
|
+
|
|
33
|
+
after(() => {
|
|
34
|
+
delete process.env.AWAIT_JOB_POLL_MS;
|
|
35
|
+
delete process.env.AWAIT_JOB_TIMEOUT_MS;
|
|
36
|
+
if (savedDBDir !== undefined) {
|
|
37
|
+
process.env.DB_DIR = savedDBDir;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
delete process.env.DB_DIR;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// TESTS
|
|
45
|
+
|
|
46
|
+
test('awaitTest rejects a malformed timestamp or job identifier', async () => {
|
|
47
|
+
const body = await response(['999999T9999', 'xyz']);
|
|
48
|
+
const details = body['response content']['details about your wait'] as any;
|
|
49
|
+
assert.ok(details.error.includes('malformed'));
|
|
50
|
+
assert.equal(body['response content']['disposition of your wait'], null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('awaitTest returns "completed" for an identifier with an already-available report', async () => {
|
|
54
|
+
const body = await response(['260101T0000', 'mix']);
|
|
55
|
+
const details = body['response content']['details about your wait'] as any;
|
|
56
|
+
assert.deepEqual(details['report identifier'], {timeStamp: '260101T0000', jobID: 'mix'});
|
|
57
|
+
const disposition = body['response content']['disposition of your wait'] as any;
|
|
58
|
+
assert.equal(disposition.outcome, 'completed');
|
|
59
|
+
assert.ok(disposition['how you can check for completion'].includes('listIssues'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('awaitTest returns an error for an identifier naming neither a report nor a pending job', async () => {
|
|
63
|
+
const body = await response(['260101T0000', 'zzz']);
|
|
64
|
+
const details = body['response content']['details about your wait'] as any;
|
|
65
|
+
assert.ok(details.error.includes('no report'));
|
|
66
|
+
assert.equal(body['response content']['disposition of your wait'], null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('awaitTest returns "failed" when the job has moved to the failed directory', async () => {
|
|
70
|
+
const failedPath = path.join(fixtureDBDir, 'jobs', 'failed', '260101T4444-flj.json');
|
|
71
|
+
await fs.mkdir(path.dirname(failedPath), {recursive: true});
|
|
72
|
+
await fs.writeFile(failedPath, JSON.stringify({target: {what: 'Failed Page', url: 'https://example.com/failed'}}));
|
|
73
|
+
try {
|
|
74
|
+
const body = await response(['260101T4444', 'flj']);
|
|
75
|
+
const disposition = body['response content']['disposition of your wait'] as any;
|
|
76
|
+
assert.equal(disposition.outcome, 'failed');
|
|
77
|
+
assert.equal(disposition['how you can check for completion'], 'Not applicable.');
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await fs.unlink(failedPath);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('awaitTest returns "timedOut" when a claimed job neither completes nor fails before the deadline', async () => {
|
|
85
|
+
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', '260101T5555-toj.json');
|
|
86
|
+
await fs.writeFile(claimedPath, JSON.stringify({target: {what: 'Stuck Page', url: 'https://example.com/stuck'}}));
|
|
87
|
+
try {
|
|
88
|
+
const body = await response(['260101T5555', 'toj']);
|
|
89
|
+
const disposition = body['response content']['disposition of your wait'] as any;
|
|
90
|
+
assert.equal(disposition.outcome, 'timedOut');
|
|
91
|
+
assert.ok(disposition['how you can check for completion'].includes('listIssues'));
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await fs.unlink(claimedPath);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('awaitTest response has no web UI equivalents for this request or its ancestors', async () => {
|
|
99
|
+
const body = await response(['260101T0000', 'mix']);
|
|
100
|
+
const similarWeb = body['URLs of similar requests for web users'] as any;
|
|
101
|
+
assert.equal(similarWeb['this request'], null);
|
|
102
|
+
assert.equal(similarWeb['closest ancestor request'], null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('awaitTest reports both orderNewTest and orderRetest as possible ancestors', async () => {
|
|
106
|
+
const body = await response(['260101T0000', 'mix']);
|
|
107
|
+
const ancestors = (body['this request'] as any)['closest ancestor request'];
|
|
108
|
+
assert.equal(ancestors.length, 2);
|
|
109
|
+
const names = ancestors.map((a: any) => a['tool name']).sort();
|
|
110
|
+
assert.deepEqual(names, ['orderNewTest', 'orderRetest']);
|
|
111
|
+
for (const ancestor of ancestors) {
|
|
112
|
+
assert.equal(ancestor.method, 'POST');
|
|
113
|
+
}
|
|
114
|
+
});
|
package/api/awaitTest.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
awaitTest.ts
|
|
3
|
+
Waits for a job ordered via orderNewTest or orderRetest to complete, and returns its outcome.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {z} from 'zod';
|
|
9
|
+
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {awaitJob, isJobID, isTimeStamp} from '../util.ts';
|
|
11
|
+
import {awaitTestResponseSchema} from './schemas.ts';
|
|
12
|
+
|
|
13
|
+
// TYPES
|
|
14
|
+
|
|
15
|
+
// The response content defined by the response schema.
|
|
16
|
+
type ResponseContent = z.infer<typeof awaitTestResponseSchema>['response content'];
|
|
17
|
+
|
|
18
|
+
// FUNCTIONS
|
|
19
|
+
|
|
20
|
+
// Returns the response body.
|
|
21
|
+
export const response = async (args: string[]) => {
|
|
22
|
+
const [timeStamp = '', jobID = ''] = args;
|
|
23
|
+
const thisHost = getThisHost();
|
|
24
|
+
// Initialize the response-content properties.
|
|
25
|
+
let requestDetails: ResponseContent['details about your wait'];
|
|
26
|
+
let requestDisposition: ResponseContent['disposition of your wait'] = null;
|
|
27
|
+
// If the timestamp or job ID is not syntactically valid:
|
|
28
|
+
if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
|
|
29
|
+
requestDetails = {
|
|
30
|
+
error: 'wait invalid: the report timeStamp or jobID is malformed'
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Otherwise, i.e. if the identifier is syntactically valid:
|
|
34
|
+
else {
|
|
35
|
+
requestDetails = {
|
|
36
|
+
'report identifier': {timeStamp, jobID}
|
|
37
|
+
};
|
|
38
|
+
// Wait for the job to complete, fail, or time out. No progress notices are sent
|
|
39
|
+
// during this wait; the caller receives only this one, final response.
|
|
40
|
+
const outcome = await awaitJob(timeStamp, jobID);
|
|
41
|
+
if (outcome === 'notFound') {
|
|
42
|
+
requestDetails = {
|
|
43
|
+
error: `wait invalid: no report with timeStamp "${timeStamp}" and jobID "${jobID}" is completed, pending, or in progress`
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else if (outcome === 'completed') {
|
|
47
|
+
requestDisposition = {
|
|
48
|
+
outcome: 'completed',
|
|
49
|
+
'what happened': 'The report is ready.',
|
|
50
|
+
'how you can check for completion': `Call the listIssues tool with timeStamp "${timeStamp}" and jobID "${jobID}" to retrieve the report.`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else if (outcome === 'failed') {
|
|
54
|
+
requestDisposition = {
|
|
55
|
+
outcome: 'failed',
|
|
56
|
+
'what happened': 'The job failed, so no report will be produced.',
|
|
57
|
+
'how you can check for completion': 'Not applicable.'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
requestDisposition = {
|
|
62
|
+
outcome: 'timedOut',
|
|
63
|
+
'what happened': 'The job was not completed within the maximum wait time, but it may still be running.',
|
|
64
|
+
'how you can check for completion': `Call the listIssues tool with timeStamp "${timeStamp}" and jobID "${jobID}" after a few minutes to check whether the report is ready.`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Create the response content.
|
|
69
|
+
const responseContent: ResponseContent = {
|
|
70
|
+
'details about your wait': requestDetails,
|
|
71
|
+
'disposition of your wait': requestDisposition
|
|
72
|
+
};
|
|
73
|
+
// Create a response body.
|
|
74
|
+
const body = {
|
|
75
|
+
'tool collection': getToolsFacts(),
|
|
76
|
+
'tool name': 'awaitTest',
|
|
77
|
+
'this request': {
|
|
78
|
+
description: 'Wait for a job ordered via the orderNewTest or orderRetest tool to complete. The timeStamp and jobID parameters are the report identifier that orderNewTest or orderRetest returned. This call blocks until the report is ready, the job fails, or a maximum wait time elapses.',
|
|
79
|
+
method: 'POST',
|
|
80
|
+
URL: `${thisHost}/api/awaitTest/${timeStamp}/${jobID}`,
|
|
81
|
+
// awaitTest's own parameters do not say whether the job was ordered via orderNewTest
|
|
82
|
+
// or orderRetest, so both are reported as possible ancestors, rather than
|
|
83
|
+
// arbitrarily naming just one.
|
|
84
|
+
'closest ancestor request': [
|
|
85
|
+
{
|
|
86
|
+
'tool name': 'orderNewTest',
|
|
87
|
+
description: 'Fulfill or reject my order to test a page about which no report is available yet.',
|
|
88
|
+
method: 'POST',
|
|
89
|
+
URL: `${thisHost}/api/orderNewTest`
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
'tool name': 'orderRetest',
|
|
93
|
+
description: 'Fulfill or reject my order to retest a page.',
|
|
94
|
+
method: 'POST',
|
|
95
|
+
URL: `${thisHost}/api/orderRetest`
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
'URLs of similar requests for web users': {
|
|
100
|
+
'this request': null,
|
|
101
|
+
'closest ancestor request': null
|
|
102
|
+
},
|
|
103
|
+
'response metadata': getResponseMetadata(),
|
|
104
|
+
'response content': responseContent
|
|
105
|
+
};
|
|
106
|
+
// Return it.
|
|
107
|
+
return body;
|
|
108
|
+
};
|
package/api/listReports.ts
CHANGED
|
@@ -53,7 +53,7 @@ export const response = async () => {
|
|
|
53
53
|
'basics about all available reports': reportsBasics,
|
|
54
54
|
'how to request that a page with no report be tested': {
|
|
55
55
|
method: 'POST',
|
|
56
|
-
URL: `${thisHost}/api/
|
|
56
|
+
URL: `${thisHost}/api/requestNewTest`,
|
|
57
57
|
'request body': {
|
|
58
58
|
description: '10- to 100-character description of the page conforming to the naming convention used in this list of reports',
|
|
59
59
|
URL: '12- to 300-character URL of the page, including the https:// scheme and any query',
|
|
@@ -62,7 +62,7 @@ export const response = async () => {
|
|
|
62
62
|
'how to check whether the request has been fulfilled': 'use this listReports tool to determine whether a report about the page has become available (typical wait time: 1 hour to 1 day)'
|
|
63
63
|
},
|
|
64
64
|
'how a web user can request that the page be tested': {
|
|
65
|
-
URL: `${thisHost}/
|
|
65
|
+
URL: `${thisHost}/requestNewTestForm.html`
|
|
66
66
|
}
|
|
67
67
|
};
|
|
68
68
|
// Create a response body.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/*
|
|
2
|
+
orderNewTest.test.ts
|
|
3
|
+
Tests for api/orderNewTest.ts using the fixture corpus.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {test, before, beforeEach, after} from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {fixtureDBDir} from '../test/dbFixture.ts';
|
|
13
|
+
|
|
14
|
+
// Blank the alert configuration unconditionally, before ./orderNewTest.ts (whose orderJob
|
|
15
|
+
// calls no alert directly, but whose redirect-rejection branch does) is ever imported
|
|
16
|
+
// below, so this file sends no real alert emails even when run directly rather than via
|
|
17
|
+
// `npm test`.
|
|
18
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
19
|
+
process.env[key] = '';
|
|
20
|
+
}
|
|
21
|
+
// Allow internal targets by default, so tests that submit an ordinary https://example.com/...
|
|
22
|
+
// URL do not depend on real DNS/network access to pass the resolution check that
|
|
23
|
+
// isAllowedTarget performs.
|
|
24
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
25
|
+
|
|
26
|
+
// SETUP AND TEARDOWN
|
|
27
|
+
|
|
28
|
+
const savedDBDir = process.env.DB_DIR;
|
|
29
|
+
const queuePath = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
30
|
+
let logged: any[] = [];
|
|
31
|
+
const originalLog = console.log;
|
|
32
|
+
|
|
33
|
+
before(() => {
|
|
34
|
+
process.env.DB_DIR = fixtureDBDir;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Empty the job queue and capture console.log before each test, so tests are
|
|
38
|
+
// order-independent and any queued jobs a test creates do not leak into later tests.
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
for (const fileName of await fs.readdir(queuePath)) {
|
|
41
|
+
await fs.unlink(path.join(queuePath, fileName));
|
|
42
|
+
}
|
|
43
|
+
logged = [];
|
|
44
|
+
console.log = (...args) => logged.push(args.join(' '));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
import {response} from './orderNewTest.ts';
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
console.log = originalLog;
|
|
51
|
+
if (savedDBDir !== undefined) {
|
|
52
|
+
process.env.DB_DIR = savedDBDir;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
delete process.env.DB_DIR;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// TESTS
|
|
60
|
+
|
|
61
|
+
test('orderNewTest rejects an empty description', async () => {
|
|
62
|
+
const body = await response(['', 'https://example.com/test', 'A reason that is long enough.']);
|
|
63
|
+
assert.ok((body['response content']['details about your order'] as any).error);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('orderNewTest rejects a reason shorter than 20 characters', async () => {
|
|
67
|
+
const body = await response(['Ordered Page', 'https://example.com/ordered', 'short']);
|
|
68
|
+
const details = body['response content']['details about your order'] as any;
|
|
69
|
+
assert.ok(details.error.includes('reason'));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('orderNewTest rejects a reason longer than 100 characters', async () => {
|
|
73
|
+
const longReason = 'x'.repeat(101);
|
|
74
|
+
const body = await response(['Ordered Page', 'https://example.com/ordered', longReason]);
|
|
75
|
+
const details = body['response content']['details about your order'] as any;
|
|
76
|
+
assert.ok(details.error.includes('reason'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('orderNewTest rejects a URL shorter than 12 characters', async () => {
|
|
80
|
+
const body = await response(['Test Page', 'short', 'A reason that is long enough.']);
|
|
81
|
+
assert.ok((body['response content']['details about your order'] as any).error);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('orderNewTest rejects a syntactically invalid URL with the correct length', async () => {
|
|
85
|
+
const body = await response(['Test Page', 'not-a-valid-url', 'A reason that is long enough.']);
|
|
86
|
+
const details = body['response content']['details about your order'] as any;
|
|
87
|
+
assert.ok(details.error.includes('invalid URL'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('orderNewTest rejects a private-address URL unless internal targets are allowed', async () => {
|
|
91
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
92
|
+
try {
|
|
93
|
+
const body = await response(['Internal Page', 'https://192.168.1.1/page', 'A reason that is long enough.']);
|
|
94
|
+
const details = body['response content']['details about your order'] as any;
|
|
95
|
+
assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
|
|
96
|
+
} finally {
|
|
97
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('orderNewTest rejects a URL that redirects to a disallowed target, and alerts a manager', async (t) => {
|
|
102
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
103
|
+
t.mock.method(globalThis, 'fetch', async () => ({url: 'https://10.0.0.5/page'}) as Response);
|
|
104
|
+
try {
|
|
105
|
+
const body = await response(['Redirecting Page', 'https://example.com/redirector', 'A reason that is long enough.']);
|
|
106
|
+
const details = body['response content']['details about your order'] as any;
|
|
107
|
+
assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
|
|
108
|
+
assert.ok(logged.some(line =>
|
|
109
|
+
line.includes('WARNING (Kilotest: instant-test order rejected for redirecting to a disallowed target)')
|
|
110
|
+
&& line.includes('https://example.com/redirector')
|
|
111
|
+
));
|
|
112
|
+
} finally {
|
|
113
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('orderNewTest rejects an already-tested page', async () => {
|
|
118
|
+
const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
|
|
119
|
+
const details = body['response content']['details about your order'] as any;
|
|
120
|
+
assert.equal(details.error, undefined);
|
|
121
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
122
|
+
assert.ok(
|
|
123
|
+
disposition['what happens next']
|
|
124
|
+
.includes('a report about a page with the same description and URL is available.')
|
|
125
|
+
);
|
|
126
|
+
assert.equal(disposition['report identifier'], null);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('orderNewTest rejects a page matching a claimed job by description', async () => {
|
|
130
|
+
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
|
|
131
|
+
await fs.writeFile(claimedPath, JSON.stringify({
|
|
132
|
+
target: {what: 'Claimed Test Page', url: 'https://example.com/claimed-job'}
|
|
133
|
+
}));
|
|
134
|
+
try {
|
|
135
|
+
const body = await response(['Claimed Test Page', 'https://example.com/other-url', 'A reason that is long enough.']);
|
|
136
|
+
const details = body['response content']['details about your order'] as any;
|
|
137
|
+
assert.equal(details.error, undefined);
|
|
138
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
139
|
+
assert.ok(
|
|
140
|
+
disposition['what happens next']
|
|
141
|
+
.includes('a request to test a page with the same description is already approved.')
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await fs.unlink(claimedPath);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('orderNewTest rejects a page matching a queued job by URL', async () => {
|
|
150
|
+
const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
|
|
151
|
+
await fs.writeFile(queuedPath, JSON.stringify({
|
|
152
|
+
target: {what: 'Some Other Page', url: 'https://example.com/queued-url'}
|
|
153
|
+
}));
|
|
154
|
+
try {
|
|
155
|
+
const body = await response(['A Different Page', 'https://example.com/queued-url', 'A reason that is long enough.']);
|
|
156
|
+
const details = body['response content']['details about your order'] as any;
|
|
157
|
+
assert.equal(details.error, undefined);
|
|
158
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
159
|
+
assert.ok(
|
|
160
|
+
disposition['what happens next']
|
|
161
|
+
.includes('a request to test a page with the same URL is already approved.')
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await fs.unlink(queuedPath);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('orderNewTest rejects an order once the job queue is full', async () => {
|
|
170
|
+
process.env.JOB_QUEUE_MAX = '0';
|
|
171
|
+
const fillerPaths = await Promise.all(Array.from({length: 1}, async (_, i) => {
|
|
172
|
+
const fillerPath = path.join(fixtureDBDir, 'jobs', 'queue', `fil${i}.json`);
|
|
173
|
+
await fs.writeFile(fillerPath, JSON.stringify({target: {what: `Filler ${i}`, url: `https://example.com/filler${i}`}}));
|
|
174
|
+
return fillerPath;
|
|
175
|
+
}));
|
|
176
|
+
delete process.env.JOB_QUEUE_MAX;
|
|
177
|
+
process.env.JOB_QUEUE_MAX = '1';
|
|
178
|
+
try {
|
|
179
|
+
const body = await response(['One Too Many Page', 'https://example.com/one-too-many', 'A reason that is long enough.']);
|
|
180
|
+
const details = body['response content']['details about your order'] as any;
|
|
181
|
+
assert.equal(details.error, undefined);
|
|
182
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
183
|
+
assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
|
|
184
|
+
} finally {
|
|
185
|
+
delete process.env.JOB_QUEUE_MAX;
|
|
186
|
+
await Promise.all(fillerPaths.map(p => fs.unlink(p)));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('orderNewTest accepts a valid new page order and enqueues a job directly', async () => {
|
|
191
|
+
const body = await response(['Brand New Ordered Page', 'https://example.com/brandnewordered', 'A reason that is long enough.']);
|
|
192
|
+
const details = body['response content']['details about your order'] as any;
|
|
193
|
+
assert.equal(details.error, undefined);
|
|
194
|
+
assert.equal(details['page to be tested'].description, 'Brand New Ordered Page');
|
|
195
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
196
|
+
assert.ok(disposition['report identifier']);
|
|
197
|
+
assert.match(disposition['report identifier'].timeStamp, /^\d{6}T\d{4}$/);
|
|
198
|
+
assert.match(disposition['report identifier'].jobID, /^[a-z0-9]{3}$/);
|
|
199
|
+
assert.ok(disposition['what happens next'].includes('Testing has begun.'));
|
|
200
|
+
assert.ok(disposition['do you want to wait for completion'].includes('awaitTest'));
|
|
201
|
+
const queuedFiles = await fs.readdir(queuePath);
|
|
202
|
+
assert.equal(queuedFiles.length, 1);
|
|
203
|
+
const job = JSON.parse(await fs.readFile(path.join(queuePath, queuedFiles[0]!), 'utf8'));
|
|
204
|
+
assert.equal(job.target.what, 'Brand New Ordered Page');
|
|
205
|
+
assert.equal(job.sources.reason, 'A reason that is long enough.');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('orderNewTest response omits a web UI URL for this request but includes the ancestor', async () => {
|
|
209
|
+
const body = await response(['Brand New Ordered Page 2', 'https://example.com/brandnewordered2', 'A reason that is long enough.']);
|
|
210
|
+
const similarWeb = body['URLs of similar requests for web users'] as any;
|
|
211
|
+
assert.equal(similarWeb['this request'], null);
|
|
212
|
+
assert.ok(similarWeb['closest ancestor request'].includes('listReports.html'));
|
|
213
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/*
|
|
2
|
+
orderNewTest.ts
|
|
3
|
+
Processes an order to test an untested page immediately, without manual approval, and returns an acknowledgement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {z} from 'zod';
|
|
9
|
+
import {getRequestFailureReason, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {checkLength, isAllowedRedirectTarget, isAllowedTarget, isURL, orderJob} from '../util.ts';
|
|
11
|
+
import {orderNewTestResponseSchema} from './schemas.ts';
|
|
12
|
+
import {sendAlert} from '../alerts.ts';
|
|
13
|
+
|
|
14
|
+
// TYPES
|
|
15
|
+
|
|
16
|
+
// The response content defined by the response schema.
|
|
17
|
+
type ResponseContent = z.infer<typeof orderNewTestResponseSchema>['response content'];
|
|
18
|
+
|
|
19
|
+
// FUNCTIONS
|
|
20
|
+
|
|
21
|
+
// Returns the response body.
|
|
22
|
+
export const response = async (args: string[]) => {
|
|
23
|
+
const [description = '', url = '', reason = ''] = args;
|
|
24
|
+
const thisHost = getThisHost();
|
|
25
|
+
// Initialize the response-content properties.
|
|
26
|
+
let requestDetails: ResponseContent['details about your order'];
|
|
27
|
+
let requestDisposition: ResponseContent['disposition of your order'] = null;
|
|
28
|
+
const descriptionCheck = checkLength(description, 1, 100, 'description of the page');
|
|
29
|
+
const reasonCheck = checkLength(reason, 20, 100, 'reason');
|
|
30
|
+
// If the description is invalid:
|
|
31
|
+
if (descriptionCheck.status === 'error') {
|
|
32
|
+
requestDetails = {
|
|
33
|
+
error: `order invalid: your ${descriptionCheck.message}`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Otherwise, if the reason is too short or too long:
|
|
37
|
+
else if (reasonCheck.status === 'error') {
|
|
38
|
+
requestDetails = {
|
|
39
|
+
error: `order invalid: your ${reasonCheck.message}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Otherwise, i.e. if the URL is too short or too long:
|
|
43
|
+
else if (url.length < 12 || url.length > 300) {
|
|
44
|
+
requestDetails = {
|
|
45
|
+
error: 'order invalid: you specified a URL for the page that is not between 12 and 300 characters long'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Otherwise, i.e. if the URL is invalid:
|
|
49
|
+
else if (!isURL(url)) {
|
|
50
|
+
requestDetails = {
|
|
51
|
+
error: 'order invalid: you specified an invalid URL for the page'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Otherwise, i.e. if the URL does not resolve to an allowed target:
|
|
55
|
+
else if (!(await isAllowedTarget(url))) {
|
|
56
|
+
requestDetails = {
|
|
57
|
+
error: 'order invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Otherwise, i.e. if the URL redirects to a target this deployment does not allow:
|
|
61
|
+
else if (!(await isAllowedRedirectTarget(url))) {
|
|
62
|
+
requestDetails = {
|
|
63
|
+
error: 'order invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
64
|
+
};
|
|
65
|
+
// Alert a manager, since a redirect to a disallowed target on an order that
|
|
66
|
+
// itself named an allowed hostname suggests an attempted or accidental SSRF,
|
|
67
|
+
// rather than an ordinary mistake by a legitimate submitter.
|
|
68
|
+
await sendAlert(
|
|
69
|
+
'Kilotest: instant-test order rejected for redirecting to a disallowed target',
|
|
70
|
+
`An order to test ${url} (described as "${description}") was rejected because it redirects to a target this deployment does not allow (e.g. a private, loopback, or link-local address).`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
// Otherwise, i.e. if the description and URL are valid:
|
|
74
|
+
else {
|
|
75
|
+
// Order the test. Unlike processTestRequest, this enqueues the job directly, with no
|
|
76
|
+
// manual-approval step, gated only by the lightweight checks orderJob itself performs
|
|
77
|
+
// (the target is not already claimed, queued, or reported, and the job queue is not
|
|
78
|
+
// already full).
|
|
79
|
+
const orderResult = await orderJob({description, url}, reason);
|
|
80
|
+
// Add details about the order.
|
|
81
|
+
requestDetails = {
|
|
82
|
+
'date and time received': new Date().toISOString(),
|
|
83
|
+
'page to be tested': {
|
|
84
|
+
description,
|
|
85
|
+
URL: url
|
|
86
|
+
},
|
|
87
|
+
'reason why the page should be tested': reason
|
|
88
|
+
};
|
|
89
|
+
// Add information about the disposition of the order.
|
|
90
|
+
if (orderResult.result === 'ok') {
|
|
91
|
+
const [timeStamp, reportJobID] = orderResult.jobID.split('-') as [string, string];
|
|
92
|
+
requestDisposition = {
|
|
93
|
+
'what happens next': 'Testing has begun. Once it finishes, the report will be available via the listIssues tool.',
|
|
94
|
+
'report identifier': {timeStamp, jobID: reportJobID},
|
|
95
|
+
'how you can check for completion': `Call the listIssues tool with timeStamp "${timeStamp}" and jobID "${reportJobID}" to learn whether the report is ready.`,
|
|
96
|
+
'do you want to wait for completion': 'Testing typically takes about 2 minutes and occasionally up to 4 minutes. Do you want to wait (and be notified of completion) instead of checking back later? If so, call the awaitTest tool with the same timeStamp and jobID.'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
requestDisposition = {
|
|
101
|
+
'what happens next': `Your order will not be processed, because ${getRequestFailureReason(
|
|
102
|
+
orderResult.result,
|
|
103
|
+
{result: 'reportExists', reason: 'a report about a page with the same description and URL is available.'}
|
|
104
|
+
)}`,
|
|
105
|
+
'report identifier': null,
|
|
106
|
+
'how you can check for completion': 'Not applicable.',
|
|
107
|
+
'do you want to wait for completion': 'Not applicable.'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Create the response content.
|
|
112
|
+
const responseContent: ResponseContent = {
|
|
113
|
+
'details about your order': requestDetails,
|
|
114
|
+
'disposition of your order': requestDisposition
|
|
115
|
+
};
|
|
116
|
+
// Create a response body.
|
|
117
|
+
const body = {
|
|
118
|
+
'tool collection': getToolsFacts(),
|
|
119
|
+
'tool name': 'orderNewTest',
|
|
120
|
+
'this request': {
|
|
121
|
+
description: 'Process my order to test a page about which no report is available yet, immediately and without manual approval. I have provided a description and the URL of the page and a reason why it should be tested.',
|
|
122
|
+
method: 'POST',
|
|
123
|
+
URL: `${thisHost}/api/orderNewTest`,
|
|
124
|
+
body: {
|
|
125
|
+
description,
|
|
126
|
+
URL: url,
|
|
127
|
+
reason
|
|
128
|
+
},
|
|
129
|
+
'closest ancestor request': {
|
|
130
|
+
'tool name': 'listReports',
|
|
131
|
+
description: 'Provide basics about all available reports.',
|
|
132
|
+
method: 'GET',
|
|
133
|
+
URL: `${thisHost}/api/listReports`
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
'URLs of similar requests for web users': {
|
|
137
|
+
'this request': null,
|
|
138
|
+
'closest ancestor request': `${thisHost}/listReports.html`
|
|
139
|
+
},
|
|
140
|
+
'response metadata': getResponseMetadata(),
|
|
141
|
+
'response content': responseContent
|
|
142
|
+
};
|
|
143
|
+
// Return it.
|
|
144
|
+
return body;
|
|
145
|
+
};
|