@jrpool/kilotest 49.0.0 → 50.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/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +19 -1
- package/api/requestFeature.ts +18 -10
- package/api/requestRetest.test.ts +39 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/docs/BACKLOG.md +14 -2
- package/docs/SERVICE.md +32 -2
- package/index.test.ts +109 -7
- package/index.ts +76 -21
- package/mcp.test.ts +1 -1
- package/package.json +1 -1
- package/util.test.ts +64 -0
- package/util.ts +24 -0
- package/web/ai0BalanceForm/index.ts +2 -2
- package/web/enqueue/index.ts +33 -42
- package/web/hideReportForm/index.ts +4 -2
- 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/unhideReportForm/index.ts +4 -2
package/api/getReport.ts
CHANGED
|
@@ -25,7 +25,9 @@ type ResponseContent = z.infer<typeof getReportResponseSchema>['response content
|
|
|
25
25
|
export const response = async (args: string[]) => {
|
|
26
26
|
const [timeStamp = '', jobID = ''] = args;
|
|
27
27
|
const thisHost = getThisHost();
|
|
28
|
-
// Get the report size.
|
|
28
|
+
// Get the report size. A malformed timestamp or job ID resolves to a nonexistent file
|
|
29
|
+
// path, which getReportStats already reports as unavailable, so no separate syntactic
|
|
30
|
+
// check is needed here.
|
|
29
31
|
const reportStats = await getReportStats(timeStamp, jobID);
|
|
30
32
|
// Create the response content.
|
|
31
33
|
const responseContent: ResponseContent = reportStats
|
|
@@ -37,6 +37,24 @@ test('requestFeature rejects an empty feature request', async () => {
|
|
|
37
37
|
);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
test('requestFeature rejects a feature request shorter than 20 characters', async () => {
|
|
41
|
+
const body = await response(['Too short']);
|
|
42
|
+
const details = body['response content']['details about your request'] as any;
|
|
43
|
+
assert.ok(details.error.includes('feature description'));
|
|
44
|
+
assert.ok(
|
|
45
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('requestFeature rejects a feature request longer than 1000 characters', async () => {
|
|
50
|
+
const body = await response(['x'.repeat(1001)]);
|
|
51
|
+
const details = body['response content']['details about your request'] as any;
|
|
52
|
+
assert.ok(details.error.includes('feature description'));
|
|
53
|
+
assert.ok(
|
|
54
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
40
58
|
test('requestFeature accepts a non-empty feature request and notifies the manager', async () => {
|
|
41
59
|
const body = await response(['Add a dark mode toggle']);
|
|
42
60
|
const details = body['response content']['details about your request'] as any;
|
|
@@ -50,7 +68,7 @@ test('requestFeature accepts a non-empty feature request and notifies the manage
|
|
|
50
68
|
});
|
|
51
69
|
|
|
52
70
|
test('requestFeature includes tool name and metadata', async () => {
|
|
53
|
-
const body = await response(['
|
|
71
|
+
const body = await response(['A sufficiently long feature description']);
|
|
54
72
|
assert.equal(body['tool name'], 'requestFeature');
|
|
55
73
|
assert.ok(body['response metadata'].identifier);
|
|
56
74
|
});
|
package/api/requestFeature.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
10
|
import {sendAlert} from '../alerts.ts';
|
|
11
|
+
import {checkLength} from '../util.ts';
|
|
11
12
|
import {requestFeatureResponseSchema} from './schemas.ts';
|
|
12
13
|
|
|
13
14
|
// TYPES
|
|
@@ -21,20 +22,27 @@ type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response co
|
|
|
21
22
|
export const response = async (args: string[]) => {
|
|
22
23
|
const [feature = ''] = args;
|
|
23
24
|
const thisHost = getThisHost();
|
|
25
|
+
const lengthCheck = checkLength(feature, 20, 1000, 'feature description');
|
|
24
26
|
// Initialize the response content.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
let responseContent: ResponseContent;
|
|
28
|
+
// If the feature description is invalid:
|
|
29
|
+
if (lengthCheck.status === 'error') {
|
|
30
|
+
responseContent = {
|
|
31
|
+
'details about your request': {
|
|
32
|
+
error: `request invalid: ${lengthCheck.message}`
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Otherwise, i.e. if it is valid:
|
|
37
|
+
else {
|
|
32
38
|
// Notify the manager.
|
|
33
39
|
await sendAlert('Kilotest: MCP feature request received', feature);
|
|
34
40
|
// Add the disposition to the response content.
|
|
35
|
-
responseContent
|
|
36
|
-
'
|
|
37
|
-
|
|
41
|
+
responseContent = {
|
|
42
|
+
'details about your request': {
|
|
43
|
+
'date and time received': new Date().toISOString(),
|
|
44
|
+
disposition: 'received and logged; manager notified'
|
|
45
|
+
}
|
|
38
46
|
};
|
|
39
47
|
}
|
|
40
48
|
// Create a response body.
|
|
@@ -15,11 +15,29 @@ import {fixtureDBDir} from '../test/dbFixture.ts';
|
|
|
15
15
|
|
|
16
16
|
const savedDBDir = process.env.DB_DIR;
|
|
17
17
|
const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
|
|
18
|
+
// A report added only to this test's disposable copy of the fixture database (never to
|
|
19
|
+
// the tracked test/fixtures/db corpus), so its 3-character job ID satisfies isJobID
|
|
20
|
+
// without perturbing report-count tests elsewhere that copy the whole corpus. Its job ID
|
|
21
|
+
// intentionally differs from the tracked corpus's 2-character 260101T0001-ct.json, which
|
|
22
|
+
// isJobID would reject.
|
|
23
|
+
const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0003-ret.json');
|
|
24
|
+
const retestFixtureReport = {
|
|
25
|
+
id: '260101T0003-ret',
|
|
26
|
+
what: 'Retest Fixture Page',
|
|
27
|
+
target: {what: 'Retest Fixture Page', url: 'https://example.com/retestfixture'},
|
|
28
|
+
sources: {worker: 'test-worker'},
|
|
29
|
+
acts: [],
|
|
30
|
+
jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
|
|
31
|
+
catalog: {},
|
|
32
|
+
images: {},
|
|
33
|
+
checkpoints: []
|
|
34
|
+
};
|
|
18
35
|
let logged: any[] = [];
|
|
19
36
|
const originalLog = console.log;
|
|
20
37
|
|
|
21
|
-
before(() => {
|
|
38
|
+
before(async () => {
|
|
22
39
|
process.env.DB_DIR = fixtureDBDir;
|
|
40
|
+
await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
|
|
23
41
|
});
|
|
24
42
|
|
|
25
43
|
// Reset the test-requests file and capture console.log, so the alert that
|
|
@@ -32,8 +50,9 @@ beforeEach(async () => {
|
|
|
32
50
|
|
|
33
51
|
import {response} from './requestRetest.ts';
|
|
34
52
|
|
|
35
|
-
after(() => {
|
|
53
|
+
after(async () => {
|
|
36
54
|
console.log = originalLog;
|
|
55
|
+
await fs.unlink(retestFixturePath);
|
|
37
56
|
if (savedDBDir !== undefined) {
|
|
38
57
|
process.env.DB_DIR = savedDBDir;
|
|
39
58
|
}
|
|
@@ -45,12 +64,19 @@ after(() => {
|
|
|
45
64
|
// TESTS
|
|
46
65
|
|
|
47
66
|
test('requestRetest rejects a nonexistent report', async () => {
|
|
48
|
-
const body = await response(['
|
|
67
|
+
const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
|
|
49
68
|
const details = body['response content']['details about your request'] as any;
|
|
50
69
|
assert.ok(details.error.includes('does not exist'));
|
|
51
70
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
52
71
|
});
|
|
53
72
|
|
|
73
|
+
test('requestRetest rejects a malformed timestamp or job identifier', async () => {
|
|
74
|
+
const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
|
|
75
|
+
const details = body['response content']['details about your request'] as any;
|
|
76
|
+
assert.ok(details.error.includes('malformed'));
|
|
77
|
+
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
78
|
+
});
|
|
79
|
+
|
|
54
80
|
test('requestRetest rejects a superseded report', async () => {
|
|
55
81
|
// 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
|
|
56
82
|
// so it is superseded and processTestRequest returns 'superseded', reported via the
|
|
@@ -66,7 +92,7 @@ test('requestRetest rejects a superseded report', async () => {
|
|
|
66
92
|
});
|
|
67
93
|
|
|
68
94
|
test('requestRetest rejects a reason shorter than 20 characters', async () => {
|
|
69
|
-
const body = await response(['
|
|
95
|
+
const body = await response(['260101T0003', 'ret', 'short']);
|
|
70
96
|
const details = body['response content']['details about your request'] as any;
|
|
71
97
|
assert.ok(details.error.includes('reason'));
|
|
72
98
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
@@ -74,20 +100,20 @@ test('requestRetest rejects a reason shorter than 20 characters', async () => {
|
|
|
74
100
|
|
|
75
101
|
test('requestRetest rejects a reason longer than 100 characters', async () => {
|
|
76
102
|
const longReason = 'x'.repeat(101);
|
|
77
|
-
const body = await response(['
|
|
103
|
+
const body = await response(['260101T0003', 'ret', longReason]);
|
|
78
104
|
const details = body['response content']['details about your request'] as any;
|
|
79
105
|
assert.ok(details.error.includes('reason'));
|
|
80
106
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
81
107
|
});
|
|
82
108
|
|
|
83
109
|
test('requestRetest rejects a report matching a claimed job by description', async () => {
|
|
84
|
-
//
|
|
110
|
+
// 260101T0003-ret is the "Retest Fixture Page" report.
|
|
85
111
|
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
|
|
86
112
|
await fs.writeFile(claimedPath, JSON.stringify({
|
|
87
|
-
target: {what: '
|
|
113
|
+
target: {what: 'Retest Fixture Page', url: 'https://example.com/unrelated'}
|
|
88
114
|
}));
|
|
89
115
|
try {
|
|
90
|
-
const body = await response(['
|
|
116
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
91
117
|
const details = body['response content']['details about your request'] as any;
|
|
92
118
|
assert.equal(details.error, undefined);
|
|
93
119
|
const disposition = body['response content']['disposition of your request'] as any;
|
|
@@ -102,13 +128,13 @@ test('requestRetest rejects a report matching a claimed job by description', asy
|
|
|
102
128
|
});
|
|
103
129
|
|
|
104
130
|
test('requestRetest rejects a report matching a queued job by URL', async () => {
|
|
105
|
-
//
|
|
131
|
+
// 260101T0003-ret's URL is https://example.com/retestfixture.
|
|
106
132
|
const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
|
|
107
133
|
await fs.writeFile(queuedPath, JSON.stringify({
|
|
108
|
-
target: {what: 'Some Other Page', url: 'https://example.com/
|
|
134
|
+
target: {what: 'Some Other Page', url: 'https://example.com/retestfixture'}
|
|
109
135
|
}));
|
|
110
136
|
try {
|
|
111
|
-
const body = await response(['
|
|
137
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
112
138
|
const details = body['response content']['details about your request'] as any;
|
|
113
139
|
assert.equal(details.error, undefined);
|
|
114
140
|
const disposition = body['response content']['disposition of your request'] as any;
|
|
@@ -123,8 +149,8 @@ test('requestRetest rejects a report matching a queued job by URL', async () =>
|
|
|
123
149
|
});
|
|
124
150
|
|
|
125
151
|
test('requestRetest rejects a duplicate request', async () => {
|
|
126
|
-
await response(['
|
|
127
|
-
const body = await response(['
|
|
152
|
+
await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
153
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
128
154
|
const details = body['response content']['details about your request'] as any;
|
|
129
155
|
assert.equal(details.error, undefined);
|
|
130
156
|
const disposition = body['response content']['disposition of your request'] as any;
|
package/api/requestRetest.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
-
import {processTestRequest} from '../util.ts';
|
|
10
|
+
import {checkLength, isJobID, isTimeStamp, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestRetestResponseSchema} from './schemas.ts';
|
|
12
12
|
|
|
13
13
|
// TYPES
|
|
@@ -21,14 +21,20 @@ type ResponseContent = z.infer<typeof requestRetestResponseSchema>['response con
|
|
|
21
21
|
export const response = async (args: string[]) => {
|
|
22
22
|
const [timeStamp = '', jobID = '', reason = ''] = args;
|
|
23
23
|
const thisHost = getThisHost();
|
|
24
|
-
const
|
|
24
|
+
const reasonCheck = checkLength(reason, 20, 100, 'reason');
|
|
25
25
|
// Initialize the response-content properties.
|
|
26
26
|
let requestDetails: ResponseContent['details about your request'];
|
|
27
27
|
let requestDisposition: ResponseContent['disposition of your request'] = null;
|
|
28
|
-
// If the
|
|
29
|
-
if (
|
|
28
|
+
// If the timestamp or job ID is not syntactically valid:
|
|
29
|
+
if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
|
|
30
30
|
requestDetails = {
|
|
31
|
-
error: 'request invalid:
|
|
31
|
+
error: 'request invalid: the report timestamp or job identifier is malformed'
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Otherwise, if the encoded reason is too short or too long:
|
|
35
|
+
else if (reasonCheck.status === 'error') {
|
|
36
|
+
requestDetails = {
|
|
37
|
+
error: `request invalid: your ${reasonCheck.message}`
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
// Otherwise, i.e. if the request is facially valid:
|
package/api/requestTest.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
-
import {isURL, processTestRequest} from '../util.ts';
|
|
10
|
+
import {checkLength, isURL, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestTestResponseSchema} from './schemas.ts';
|
|
12
12
|
|
|
13
13
|
// TYPES
|
|
@@ -24,14 +24,14 @@ export const response = async (args: string[]) => {
|
|
|
24
24
|
// Initialize the response-content properties.
|
|
25
25
|
let requestDetails: ResponseContent['details about your request'];
|
|
26
26
|
let requestDisposition: ResponseContent['disposition of your request'] = null;
|
|
27
|
-
const
|
|
28
|
-
// If the description is
|
|
29
|
-
if (
|
|
27
|
+
const descriptionCheck = checkLength(description, 1, 100, 'description of the page');
|
|
28
|
+
// If the description is invalid:
|
|
29
|
+
if (descriptionCheck.status === 'error') {
|
|
30
30
|
requestDetails = {
|
|
31
|
-
error:
|
|
31
|
+
error: `request invalid: your ${descriptionCheck.message}`
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
// Otherwise, i.e. if the URL is too short or too long
|
|
34
|
+
// Otherwise, i.e. if the URL is too short or too long:
|
|
35
35
|
else if (url.length < 12 || url.length > 300) {
|
|
36
36
|
requestDetails = {
|
|
37
37
|
error: 'request invalid: you specified a URL for the page that is not between 12 and 300 characters long'
|
package/api/schemas.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const getReportSchema = {
|
|
|
43
43
|
|
|
44
44
|
// requestTest: POST /api/requestTest
|
|
45
45
|
export const requestTestSchema = {
|
|
46
|
-
description: z.string().describe('
|
|
46
|
+
description: z.string().describe('1- to 100-character description of the page conforming to the naming convention used in the listReports output'),
|
|
47
47
|
URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
|
|
48
48
|
reason: z.string().describe('20- to 100-character reason why the page should be tested')
|
|
49
49
|
};
|
|
@@ -57,7 +57,7 @@ export const requestRetestSchema = {
|
|
|
57
57
|
|
|
58
58
|
// requestFeature: POST /api/requestFeature
|
|
59
59
|
export const requestFeatureSchema = {
|
|
60
|
-
feature: z.string().describe('description of requested feature improvement or new feature')
|
|
60
|
+
feature: z.string().describe('20- to 1000-character description of requested feature improvement or new feature')
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
// listReports takes no input; omitted (mcp.ts already uses inputSchema: {}).
|
package/docs/BACKLOG.md
CHANGED
|
@@ -4,9 +4,21 @@ Engineering tasks and risks that are not yet scheduled.
|
|
|
4
4
|
|
|
5
5
|
Items marked completed or not adopted are preserved for about 2 weeks in case of production bugs.
|
|
6
6
|
|
|
7
|
-
## Make validation locations consistent
|
|
7
|
+
## Make validation locations consistent (completed)
|
|
8
8
|
|
|
9
|
-
Inventory where request validations occur, e.g. in `index.ts` versus in the modules to which it routes requests. Find any arbitrary inconsistencies in the locations. If any are found, decide where it is most parsimonious and maintainable for the validation to take place and standardize on that.
|
|
9
|
+
Inventory where request validations occur, e.g. in `index.ts` versus in the modules to which it routes requests. Find any arbitrary inconsistencies in the locations. If any are found, decide where it is most parsimonious and maintainable for the validation to take place and, when the user approves, standardize on that.
|
|
10
|
+
|
|
11
|
+
### Inventory findings
|
|
12
|
+
|
|
13
|
+
Validation was spread across `index.ts`, the `web/*` modules it dispatches to, and the `api/*.ts` modules shared by the HTTP API and the MCP tools (`mcp.ts` itself performs no validation; it delegates entirely to `api/*.ts`, whose zod `inputSchema`s in `api/schemas.ts` are shape-only, documenting length/format bounds in `.describe()` prose rather than enforcing them). Several arbitrary inconsistencies were found and fixed:
|
|
14
|
+
|
|
15
|
+
- **A URL was validated three different, inconsistent ways** for what is logically the same field: a loose inline `url.startsWith('https://')` check in `index.ts` (for both `requestTest.html` and `requestAction.html`), and the correct, stricter shared `isURL()` helper (already used by `api/requestTest.ts` and `web/enqueue/index.ts`) nowhere in `index.ts` itself. Fixed by having `index.ts` call `isURL()` at both sites.
|
|
16
|
+
- **`requestAction.html`'s URL and authorization code were validated twice**: once in `index.ts` before dispatch, and again inside `web/enqueue/index.ts`'s own `answer()`, using different strictness for the URL check. Once both checks used the same `isURL()`/`isValidAuthCode()` helpers, the inner check in `enqueue` became strictly unreachable-as-false (its only caller, `index.ts`, already guarantees both conditions), so it was removed; `enqueue` now trusts its caller, consistent with `web/requestTest/` and `web/requestRetest/`.
|
|
17
|
+
- **The authorization-code check (`authCode === process.env.AUTH_CODE`) was copy-pasted verbatim across 8 separate modules** (`hideReportForm`, `unhideReportForm`, `ai0BalanceForm`, `showHiddenReportsForm`, `metrics`, `renewWCAG`, `reannotate`, and, via the already-shared `web/reportDeletion.ts`, `pruneReportsForm`/`expungeReportsForm`/`rewindReportsForm`). Consolidated into one shared `isValidAuthCode()` helper in `util.ts`, called everywhere the check occurs.
|
|
18
|
+
- **`api/requestRetest.ts` never validated `timeStamp`/`jobID` syntactically**, unlike the equivalent web path (`index.ts`, `requestRetest.html`) and the equivalent worker path, both of which call `isTimeStamp`/`isJobID` before proceeding; it relied solely on the report lookup failing gracefully. Added the same `isTimeStamp`/`isJobID` pre-check, matching the web path, with its own dedicated (non-corpus) test fixture since the shared 2-character fixture job IDs used elsewhere in the test suite do not satisfy the real, production 3-character job ID format `isJobID` enforces.
|
|
19
|
+
- **`api/requestFeature.ts` had no length bound at all** (only a truthiness check), unlike every other free-text field in the codebase (comments: 20-1000 characters; `reason`/`why`: 20-100). Added a matching 20-1000 character bound via a new, generic `checkLength()` helper (generalized from the existing `checkCommentLength()`, which was already comment-specific and left unchanged), and corrected `api/schemas.ts`'s description accordingly.
|
|
20
|
+
- **A documentation/code mismatch**: `api/schemas.ts` described `requestTest`'s `description` field as "10- to 100-character" while the code actually enforced (and still enforces) 1-100. Corrected the description to match the code's real, existing behavior.
|
|
21
|
+
- **`api/getReport.ts`, `api/listIssues.ts`, `api/listViolators.ts`, `api/listDiagnoses.ts` perform no syntactic validation of `timeStamp`/`jobID`/`issueID`/`catalogIndex`**, unlike routes elsewhere that call `isTimeStamp`/`isJobID` before proceeding. Investigated as a candidate gap, but not changed: these four read-only operations resolve straight to a file-path lookup (`getReport`/`getReportStats`) that already fails gracefully for any malformed or nonexistent input, so a syntactic pre-check would produce an identical outcome through a different code path, with no functional benefit. This is a legitimate, already-consistent "fail-on-use" validation strategy for pure lookups, distinct from (and not required to match) the "fail-fast" strategy used for mutating requests elsewhere.
|
|
10
22
|
|
|
11
23
|
## Protect hidden report list (completed)
|
|
12
24
|
|
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,6 +244,16 @@ 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
259
|
# Specify the only paths of forwardable requests. UptimeRobet uses HEAD.
|
|
@@ -278,6 +299,15 @@ This configuration can be futher tightened if experience warrants.
|
|
|
278
299
|
|
|
279
300
|
The `flush_interval` setting prevents granular reporting by Testaro workers from being buffered, so the updates reach the browser without delay.
|
|
280
301
|
|
|
302
|
+
### Request logging
|
|
303
|
+
|
|
304
|
+
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:
|
|
305
|
+
|
|
306
|
+
- **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.
|
|
307
|
+
- **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.
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
281
311
|
## Version management
|
|
282
312
|
|
|
283
313
|
When a new version of the `kilotest` package has been published, the service can be updated as follows:
|
|
@@ -310,7 +340,7 @@ The smoke-test check is defined in `.github/workflows/smoke-test.yml` and runs `
|
|
|
310
340
|
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
341
|
|
|
312
342
|
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.
|
|
343
|
+
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
344
|
1. Open or update the pull request.
|
|
315
345
|
1. The smoke test workflow runs and verifies that all paths, including the new one, are forwarded by Caddy.
|
|
316
346
|
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.
|
package/index.test.ts
CHANGED
|
@@ -826,6 +826,40 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
|
|
|
826
826
|
assert.equal(res.headers['location'], '/tutorialAI.html');
|
|
827
827
|
});
|
|
828
828
|
|
|
829
|
+
test('GET /diagnoses.html/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
|
|
830
|
+
const res = await request(
|
|
831
|
+
'GET', '/diagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body', {}, {followRedirects: false}
|
|
832
|
+
);
|
|
833
|
+
assert.equal(res.statusCode, 301);
|
|
834
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test('GET /reportIssues.html/<timeStamp>/<jobID> redirects to the equivalent /listIssues.html/<timeStamp>/<jobID>', async () => {
|
|
838
|
+
const res = await request('GET', '/reportIssues.html/260101T0000/mix', {}, {followRedirects: false});
|
|
839
|
+
assert.equal(res.statusCode, 301);
|
|
840
|
+
assert.equal(res.headers['location'], '/listIssues.html/260101T0000/mix');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('GET /reportIssue.html/<segments> redirects to the equivalent /listViolators.html/<segments>', async () => {
|
|
844
|
+
const res = await request(
|
|
845
|
+
'GET', '/reportIssue.html/duplicateID/260101T0000/mix', {}, {followRedirects: false}
|
|
846
|
+
);
|
|
847
|
+
assert.equal(res.statusCode, 301);
|
|
848
|
+
assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000/mix');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('GET /rules.html/<segment> redirects to the equivalent /listRules.html/<segment>', async () => {
|
|
852
|
+
const res = await request('GET', '/rules.html/allCaps', {}, {followRedirects: false});
|
|
853
|
+
assert.equal(res.statusCode, 301);
|
|
854
|
+
assert.equal(res.headers['location'], '/listRules.html/allCaps');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test('GET /targets.html redirects to /listReports.html', async () => {
|
|
858
|
+
const res = await request('GET', '/targets.html', {}, {followRedirects: false});
|
|
859
|
+
assert.equal(res.statusCode, 301);
|
|
860
|
+
assert.equal(res.headers['location'], '/listReports.html');
|
|
861
|
+
});
|
|
862
|
+
|
|
829
863
|
test('POST /requestTest.html with non-https URL returns an error', async () => {
|
|
830
864
|
const res = await formRequest('POST', '/requestTest.html', {
|
|
831
865
|
description: 'Test Page',
|
|
@@ -1545,7 +1579,7 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
|
|
|
1545
1579
|
assert.ok(res.body.includes('Test request duplicates an already submitted request'));
|
|
1546
1580
|
});
|
|
1547
1581
|
|
|
1548
|
-
test('POST /requestAction.html with valid auth code
|
|
1582
|
+
test('POST /requestAction.html with valid auth code approves the same target twice without error', {timeout: 500}, async () => {
|
|
1549
1583
|
await fs.writeFile(testRequestsPath, '{}\n');
|
|
1550
1584
|
// Create a request.
|
|
1551
1585
|
await formRequest('POST', '/requestTest.html', {
|
|
@@ -1559,20 +1593,19 @@ test('POST /requestAction.html with valid auth code and approval of a duplicate
|
|
|
1559
1593
|
authCode: 'test-auth-code',
|
|
1560
1594
|
what: 'yes'
|
|
1561
1595
|
});
|
|
1562
|
-
// Approve it again
|
|
1563
|
-
//
|
|
1596
|
+
// Approve it again. enqueue.answer has no duplicate-detection logic of its own (that
|
|
1597
|
+
// lives in processTestRequest, upstream of approval), so a second approval of the same
|
|
1598
|
+
// target queues a second job rather than erroring.
|
|
1564
1599
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1565
1600
|
target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
|
|
1566
1601
|
authCode: 'test-auth-code',
|
|
1567
1602
|
what: 'yes'
|
|
1568
1603
|
});
|
|
1569
|
-
|
|
1570
|
-
assert.ok(res.statusCode === 200 || res.statusCode === 400);
|
|
1604
|
+
assert.equal(res.statusCode, 200);
|
|
1571
1605
|
});
|
|
1572
1606
|
|
|
1573
1607
|
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.
|
|
1608
|
+
// A URL that starts with https:// but is not a valid URL fails index.ts's own isURL check.
|
|
1576
1609
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1577
1610
|
target: 'https://\tTest Page',
|
|
1578
1611
|
authCode: 'test-auth-code',
|
|
@@ -1646,6 +1679,75 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
|
|
|
1646
1679
|
assert.equal(result.reason, 'test reason');
|
|
1647
1680
|
});
|
|
1648
1681
|
|
|
1682
|
+
// TESTS FOR general per-request logging
|
|
1683
|
+
|
|
1684
|
+
test('a request logs a single-line JSON record with the expected fields', async () => {
|
|
1685
|
+
const logged: string[] = [];
|
|
1686
|
+
const originalLog = console.log;
|
|
1687
|
+
console.log = (line: string) => logged.push(line);
|
|
1688
|
+
try {
|
|
1689
|
+
await request('GET', '/listReports.html');
|
|
1690
|
+
}
|
|
1691
|
+
finally {
|
|
1692
|
+
console.log = originalLog;
|
|
1693
|
+
}
|
|
1694
|
+
const requestLines = logged.filter(line => {
|
|
1695
|
+
try {
|
|
1696
|
+
return JSON.parse(line).type === 'request';
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
assert.equal(requestLines.length, 1);
|
|
1703
|
+
const record = JSON.parse(requestLines[0]!);
|
|
1704
|
+
assert.equal(record.method, 'GET');
|
|
1705
|
+
assert.equal(record.path, '/listReports.html');
|
|
1706
|
+
assert.equal(record.status, 200);
|
|
1707
|
+
assert.ok(record.ip);
|
|
1708
|
+
assert.ok(record.userAgent);
|
|
1709
|
+
assert.ok(record.time);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
test('a request logs its path without its query string', async () => {
|
|
1713
|
+
const logged: string[] = [];
|
|
1714
|
+
const originalLog = console.log;
|
|
1715
|
+
console.log = (line: string) => logged.push(line);
|
|
1716
|
+
try {
|
|
1717
|
+
await request('GET', '/listIssues.html/260101T0000/mix?authCode=secret');
|
|
1718
|
+
}
|
|
1719
|
+
finally {
|
|
1720
|
+
console.log = originalLog;
|
|
1721
|
+
}
|
|
1722
|
+
const record = logged
|
|
1723
|
+
.map(line => { try {return JSON.parse(line);} catch {return null;} })
|
|
1724
|
+
.find(parsed => parsed?.type === 'request');
|
|
1725
|
+
assert.ok(record);
|
|
1726
|
+
assert.equal(record.path, '/listIssues.html/260101T0000/mix');
|
|
1727
|
+
assert.ok(!JSON.stringify(record).includes('secret'));
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
test('a smoke-test-flagged request is not logged as a general request', async () => {
|
|
1731
|
+
const logged: string[] = [];
|
|
1732
|
+
const originalLog = console.log;
|
|
1733
|
+
console.log = (line: string) => logged.push(line);
|
|
1734
|
+
try {
|
|
1735
|
+
await request('GET', '/listReports.html', null, {'x-kilotest-smoke': '1'});
|
|
1736
|
+
}
|
|
1737
|
+
finally {
|
|
1738
|
+
console.log = originalLog;
|
|
1739
|
+
}
|
|
1740
|
+
const requestLines = logged.filter(line => {
|
|
1741
|
+
try {
|
|
1742
|
+
return JSON.parse(line).type === 'request';
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
assert.equal(requestLines.length, 0);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1649
1751
|
// UNIT TESTS FOR startServer
|
|
1650
1752
|
|
|
1651
1753
|
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,
|
|
@@ -260,6 +262,17 @@ const selfSubmittingManagerPages = new Set([
|
|
|
260
262
|
// unhideReportForm is served only by showHiddenReportsForm, once a valid authorization code
|
|
261
263
|
// has been submitted), so that the names of hidden reports are never disclosed without one.
|
|
262
264
|
const noDirectGetPages = new Set(['unhideReportForm']);
|
|
265
|
+
// Old page names, from before a 2026-08-31 rename (see GitHub issue #3), that search
|
|
266
|
+
// engines and other crawlers are still requesting. Redirecting them lets crawlers update
|
|
267
|
+
// their own indexes, rather than leaving them to repeat the same dead request indefinitely.
|
|
268
|
+
// TEMPORARY: retire this redirect after 2027-04-01 once crawlers have re-indexed.
|
|
269
|
+
const renamedPagePrefixes: Record<string, string> = {
|
|
270
|
+
diagnoses: 'listDiagnoses',
|
|
271
|
+
reportIssue: 'listViolators',
|
|
272
|
+
reportIssues: 'listIssues',
|
|
273
|
+
rules: 'listRules',
|
|
274
|
+
targets: 'listReports'
|
|
275
|
+
};
|
|
263
276
|
const jobLock = createLock();
|
|
264
277
|
|
|
265
278
|
// FUNCTIONS
|
|
@@ -309,16 +322,22 @@ export const serveError = async (error: Record<string, unknown>, response: Serve
|
|
|
309
322
|
console.log('Cannot send error response because the response has ended.');
|
|
310
323
|
}
|
|
311
324
|
};
|
|
325
|
+
// Returns the IP address a request should be attributed to: the value of its
|
|
326
|
+
// x-forwarded-for header (Caddy runs in front of Kilotest, so a real client's address
|
|
327
|
+
// arrives this way, not as the socket's own remote address), else the socket's remote
|
|
328
|
+
// address, else 'unknown' if neither is available.
|
|
329
|
+
export const getRequestIP = (request: IncomingMessage): string | string[] => {
|
|
330
|
+
const forwardedFor = request.headers['x-forwarded-for'];
|
|
331
|
+
const remoteAddress = request.socket.remoteAddress;
|
|
332
|
+
return forwardedFor || remoteAddress || 'unknown';
|
|
333
|
+
};
|
|
312
334
|
// Creates an error object about a suspicious request.
|
|
313
335
|
export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
|
|
314
336
|
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
337
|
return {
|
|
319
338
|
message: 'Invalid request',
|
|
320
339
|
reason,
|
|
321
|
-
'IP address':
|
|
340
|
+
'IP address': getRequestIP(request),
|
|
322
341
|
method,
|
|
323
342
|
URL: url,
|
|
324
343
|
'user agent': headers['user-agent'] || 'none',
|
|
@@ -326,6 +345,39 @@ export const getAbuseError = (request: IncomingMessage, reason: string | undefin
|
|
|
326
345
|
time: new Date().toISOString()
|
|
327
346
|
};
|
|
328
347
|
};
|
|
348
|
+
// Logs one line for every request, successful or not, once it finishes. Unlike
|
|
349
|
+
// getAbuseError (logged only for rejected requests, as a rare, human-read-in-the-moment
|
|
350
|
+
// dump), this is a terse, single-line-per-request JSON record, intended to be high-volume
|
|
351
|
+
// and machine-parseable, so that any kind of suspicious traffic pattern (not only the
|
|
352
|
+
// specific rejection reasons getAbuseError already covers) can be recognized later. It
|
|
353
|
+
// deliberately does not consult isMetricsExcluded: that exclusion exists to keep the
|
|
354
|
+
// maintainer's own manual testing out of usage counts, but abuse visibility should see
|
|
355
|
+
// all traffic, including the maintainer's own, or it recreates the same kind of blind
|
|
356
|
+
// spot this logging exists to close.
|
|
357
|
+
const logRequestOnFinish = (request: IncomingMessage, response: ServerResponse): void => {
|
|
358
|
+
const {method, headers} = request;
|
|
359
|
+
const ip = getRequestIP(request);
|
|
360
|
+
const userAgent = headers['user-agent'] || 'none';
|
|
361
|
+
const path = new URL(request.url as string, 'https://localhost:3000').pathname;
|
|
362
|
+
const originalEnd = response.end.bind(response);
|
|
363
|
+
// Reassign response.end so that every path through handleRequest logs exactly once,
|
|
364
|
+
// right before the response is actually sent, rather than requiring every dispatch
|
|
365
|
+
// branch to remember to log itself (the same class of gap that left successful
|
|
366
|
+
// requests unlogged in the first place). No query string is logged, so that a
|
|
367
|
+
// parameter such as authCode is never written to a log file.
|
|
368
|
+
response.end = ((...args: Parameters<typeof originalEnd>) => {
|
|
369
|
+
console.log(JSON.stringify({
|
|
370
|
+
type: 'request',
|
|
371
|
+
time: new Date().toISOString(),
|
|
372
|
+
ip,
|
|
373
|
+
method,
|
|
374
|
+
path,
|
|
375
|
+
status: response.statusCode,
|
|
376
|
+
userAgent
|
|
377
|
+
}));
|
|
378
|
+
return originalEnd(...args);
|
|
379
|
+
}) as typeof response.end;
|
|
380
|
+
};
|
|
329
381
|
// Returns whether a request carries the metrics-exclusion cookie with the value that
|
|
330
382
|
// proves it. A request without a valid AUTH_CODE configured can never match, since
|
|
331
383
|
// getExclusionCookieValue then hashes an empty string, which no cookie should equal.
|
|
@@ -487,6 +539,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
487
539
|
response.end('{}');
|
|
488
540
|
return;
|
|
489
541
|
}
|
|
542
|
+
// Log this request once it finishes, for general abuse/usage visibility (see GitHub
|
|
543
|
+
// issue #3). Placed after the smoke-test short-circuit above, so CI's own smoke-test
|
|
544
|
+
// traffic is excluded from this log exactly as it is already excluded from metrics.
|
|
545
|
+
logRequestOnFinish(request, response);
|
|
490
546
|
// Records a pageViews or apiOperations metric, unless this request's browser has proven,
|
|
491
547
|
// via a valid authCode submitted earlier on metrics.html, that it belongs to the
|
|
492
548
|
// maintainer testing Kilotest manually rather than to real usage.
|
|
@@ -636,13 +692,21 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
636
692
|
// Otherwise, if it is for an HTML page other than the home page:
|
|
637
693
|
else if (pageName.endsWith('.html')) {
|
|
638
694
|
const topic = pageName.slice(0, -5);
|
|
639
|
-
// If
|
|
695
|
+
// If it is for a page's old, pre-rename name:
|
|
696
|
+
if (renamedPagePrefixes[topic]) {
|
|
697
|
+
// Redirect the client permanently to the current page name, preserving any
|
|
698
|
+
// further path segments and the query string.
|
|
699
|
+
const newLocation = `/${renamedPagePrefixes[topic]}.html${pathTail ? `/${pathTail}` : ''}${search}`;
|
|
700
|
+
response.writeHead(301, {Location: newLocation});
|
|
701
|
+
response.end();
|
|
702
|
+
}
|
|
703
|
+
// Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
|
|
640
704
|
// the '*.html*' GET pattern, but its handler expects POST's argument list and
|
|
641
705
|
// performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
|
|
642
706
|
// too, but its handler serves both methods the same way, so it is not excluded here,
|
|
643
707
|
// except for a page in noDirectGetPages, which stays excluded from GET even though
|
|
644
708
|
// it is self-submitting, since it must be reachable only via another page's POST):
|
|
645
|
-
if (
|
|
709
|
+
else if (
|
|
646
710
|
answer[topic] &&
|
|
647
711
|
!noDirectGetPages.has(topic) &&
|
|
648
712
|
(!isPathAllowed('POST', pathname) || selfSubmittingManagerPages.has(topic))
|
|
@@ -815,7 +879,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
815
879
|
else if (pageName === 'requestTest.html') {
|
|
816
880
|
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
817
881
|
// If the request is valid:
|
|
818
|
-
if (description && url
|
|
882
|
+
if (description && isURL(url) && why) {
|
|
819
883
|
// If a report on the page is already available:
|
|
820
884
|
if (await isReportAvailable(description, url)) {
|
|
821
885
|
// Report the error.
|
|
@@ -877,7 +941,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
877
941
|
const {target, authCode} = postData as {target: string; authCode?: string};
|
|
878
942
|
const [url, description] = target.split('\t') as [string, string];
|
|
879
943
|
// If the request is valid:
|
|
880
|
-
if (url
|
|
944
|
+
if (isURL(url) && isValidAuthCode(authCode)) {
|
|
881
945
|
// Set the non-location headers for a response.
|
|
882
946
|
setHeaders('text/html', null, 'ultra');
|
|
883
947
|
// If the request is an approval:
|
|
@@ -885,19 +949,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
885
949
|
// Set a location header for a response.
|
|
886
950
|
response.setHeader('content-location', pathname);
|
|
887
951
|
// 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
|
-
}
|
|
952
|
+
const answerData = await answer.enqueue(url, description);
|
|
953
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
954
|
+
// Serve the test-order page with the remaining recommendations.
|
|
955
|
+
response.end(answerData.answerPage);
|
|
901
956
|
}
|
|
902
957
|
// Otherwise, i.e. if it is a rejection:
|
|
903
958
|
else {
|
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');
|
package/package.json
CHANGED
package/util.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
annotateReportObject,
|
|
14
14
|
checkCommentDuplicate,
|
|
15
15
|
checkCommentLength,
|
|
16
|
+
checkLength,
|
|
16
17
|
createLock,
|
|
17
18
|
dbPath,
|
|
18
19
|
getAgoDays,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
isReportAvailable,
|
|
46
47
|
isTimeStamp,
|
|
47
48
|
isURL,
|
|
49
|
+
isValidAuthCode,
|
|
48
50
|
jobsPath,
|
|
49
51
|
makeBreakable,
|
|
50
52
|
minifyURL,
|
|
@@ -83,6 +85,32 @@ test('checkCommentLength rejects a comment longer than 1000 characters', () => {
|
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
|
|
88
|
+
test('checkLength accepts text at the minimum bound', () => {
|
|
89
|
+
const result = checkLength('a'.repeat(5), 5, 10, 'reason');
|
|
90
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('checkLength accepts text at the maximum bound', () => {
|
|
94
|
+
const result = checkLength('a'.repeat(10), 5, 10, 'reason');
|
|
95
|
+
assert.deepEqual(result, {status: 'ok'});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('checkLength rejects text shorter than the minimum bound', () => {
|
|
99
|
+
const result = checkLength('a'.repeat(4), 5, 10, 'reason');
|
|
100
|
+
assert.deepEqual(result, {
|
|
101
|
+
status: 'error',
|
|
102
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('checkLength rejects text longer than the maximum bound', () => {
|
|
107
|
+
const result = checkLength('a'.repeat(11), 5, 10, 'reason');
|
|
108
|
+
assert.deepEqual(result, {
|
|
109
|
+
status: 'error',
|
|
110
|
+
message: 'The reason must be between 5 and 10 characters long'
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
86
114
|
test('checkCommentDuplicate accepts a comment with no existing comments', () => {
|
|
87
115
|
const result = checkCommentDuplicate([], 'a'.repeat(20));
|
|
88
116
|
assert.deepEqual(result, {status: 'ok'});
|
|
@@ -337,6 +365,42 @@ test('isURL returns false for an invalid URL', () => {
|
|
|
337
365
|
assert.equal(isURL('not-a-url'), false);
|
|
338
366
|
});
|
|
339
367
|
|
|
368
|
+
test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
|
|
369
|
+
const saved = process.env.AUTH_CODE;
|
|
370
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
371
|
+
try {
|
|
372
|
+
assert.equal(isValidAuthCode('secret-code'), true);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
if (saved === undefined) {
|
|
376
|
+
delete process.env.AUTH_CODE;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
process.env.AUTH_CODE = saved;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('isValidAuthCode returns false when the code does not match AUTH_CODE', () => {
|
|
385
|
+
const saved = process.env.AUTH_CODE;
|
|
386
|
+
process.env.AUTH_CODE = 'secret-code';
|
|
387
|
+
try {
|
|
388
|
+
assert.equal(isValidAuthCode('wrong-code'), false);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
if (saved === undefined) {
|
|
392
|
+
delete process.env.AUTH_CODE;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
process.env.AUTH_CODE = saved;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('isValidAuthCode returns false for an undefined code', () => {
|
|
401
|
+
assert.equal(isValidAuthCode(undefined), false);
|
|
402
|
+
});
|
|
403
|
+
|
|
340
404
|
test('makeBreakable inserts wbr before non-initial slashes', () => {
|
|
341
405
|
assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
|
|
342
406
|
});
|
package/util.ts
CHANGED
|
@@ -173,6 +173,24 @@ export type ReportData = {
|
|
|
173
173
|
const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'});
|
|
174
174
|
// Sorts strings alphabetically and case-insensitively.
|
|
175
175
|
const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b));
|
|
176
|
+
// Returns whether a string's length is within the given bounds. Generic length check
|
|
177
|
+
// shared by any field with a min/max character-count rule (e.g. a request description
|
|
178
|
+
// or reason), so each caller states its own bounds and label instead of hardcoding an
|
|
179
|
+
// ad hoc length comparison.
|
|
180
|
+
export const checkLength = (
|
|
181
|
+
text: string, min: number, max: number, label: string
|
|
182
|
+
): {status: 'ok'} | {status: 'error'; message: string} => {
|
|
183
|
+
const {length} = text;
|
|
184
|
+
// If the length is invalid:
|
|
185
|
+
if (length < min || length > max) {
|
|
186
|
+
// Return the applicable error message.
|
|
187
|
+
return {
|
|
188
|
+
status: 'error',
|
|
189
|
+
message: `The ${label} must be between ${min} and ${max} characters long`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {status: 'ok'};
|
|
193
|
+
};
|
|
176
194
|
// Returns whether a comment's raw length is within the 20-to-1000-character bounds.
|
|
177
195
|
export const checkCommentLength = (
|
|
178
196
|
content: string
|
|
@@ -536,6 +554,12 @@ export const isURL = (string: string): boolean => {
|
|
|
536
554
|
return false;
|
|
537
555
|
}
|
|
538
556
|
};
|
|
557
|
+
// Returns whether a string is the authorization code, the shared check every
|
|
558
|
+
// manager-facing page and action uses to gate a submission. Centralized so all call
|
|
559
|
+
// sites compare against process.env.AUTH_CODE the same way.
|
|
560
|
+
export const isValidAuthCode = (authCode: string | undefined | null): boolean => {
|
|
561
|
+
return !!authCode && authCode === process.env.AUTH_CODE;
|
|
562
|
+
};
|
|
539
563
|
// Makes a string breakable before non-initial slashes.
|
|
540
564
|
export const makeBreakable = (string: string): string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
|
|
541
565
|
// Minifies a URL for duplicate detection.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import {balancePath} from '../../balances.ts';
|
|
10
|
-
import {getJSON, populateTemplate} from '../../util.ts';
|
|
10
|
+
import {getJSON, isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
22
22
|
// If the form has been submitted:
|
|
23
23
|
if (method === 'POST' && newBalanceString) {
|
|
24
24
|
// If the authorization code is valid:
|
|
25
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
26
26
|
const newBalance = Number.parseFloat(newBalanceString);
|
|
27
27
|
// If the new balance is valid:
|
|
28
28
|
if (
|
package/web/enqueue/index.ts
CHANGED
|
@@ -6,54 +6,45 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
deleteTestRequests, getJSON, getNowStamp, getRandomString,
|
|
9
|
+
deleteTestRequests, getJSON, getNowStamp, getRandomString, jobsPath, populateTemplate
|
|
10
10
|
} from '../../util.ts';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
// FUNCTIONS
|
|
15
15
|
|
|
16
|
-
// Implements a test request approval and returns a revised request page.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
status: 'ok',
|
|
48
|
-
answerPage
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
// Otherwise, i.e. if the authorization code is invalid, return an error page. The
|
|
52
|
-
// message is deliberately vague so as not to confirm to an attacker that the
|
|
53
|
-
// authorization code specifically (as opposed to some other part of the request) is
|
|
54
|
-
// what was wrong.
|
|
16
|
+
// Implements a test request approval and returns a revised request page. The caller
|
|
17
|
+
// (index.ts, this function's only caller) has already validated the URL and
|
|
18
|
+
// authorization code before invoking this function.
|
|
19
|
+
export const answer = async (url: string, description: string) => {
|
|
20
|
+
// Get the job template.
|
|
21
|
+
const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, '..', '..', 'job.json'), 'utf8');
|
|
22
|
+
const job = JSON.parse(jobTemplateJSON);
|
|
23
|
+
const nowStamp = getNowStamp();
|
|
24
|
+
// Populate the template with job properties.
|
|
25
|
+
const jobIDSuffix = getRandomString(3);
|
|
26
|
+
const jobName = `${nowStamp}-${jobIDSuffix}`;
|
|
27
|
+
job.id = jobName;
|
|
28
|
+
job.creationTimeStamp = nowStamp;
|
|
29
|
+
job.executionTimeStamp = nowStamp;
|
|
30
|
+
job.target.what = description;
|
|
31
|
+
job.target.url = url;
|
|
32
|
+
const query: Record<string, string> = {
|
|
33
|
+
target: description,
|
|
34
|
+
jobName
|
|
35
|
+
};
|
|
36
|
+
// Save the job in the queue.
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job)
|
|
39
|
+
);
|
|
40
|
+
console.log(`Retest queued for ${description} as job ${jobName}`);
|
|
41
|
+
// Delete the test requests for the target.
|
|
42
|
+
await deleteTestRequests(url);
|
|
43
|
+
// Get the populated answer template.
|
|
44
|
+
const answerPage = await populateTemplate(import.meta.dirname, query);
|
|
45
|
+
// Return the populated page.
|
|
55
46
|
return {
|
|
56
|
-
status: '
|
|
57
|
-
|
|
47
|
+
status: 'ok',
|
|
48
|
+
answerPage
|
|
58
49
|
};
|
|
59
50
|
};
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getReportExtracts, hiddenReportsPath, isValidAuthCode, populateTemplate, reportsPath
|
|
10
|
+
} from '../../util.ts';
|
|
9
11
|
import fs from 'node:fs/promises';
|
|
10
12
|
import path from 'node:path';
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
20
22
|
// If the form has been submitted and a report is to be hidden:
|
|
21
23
|
if (method === 'POST' && jobName) {
|
|
22
24
|
// If the authorization code is valid:
|
|
23
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
24
26
|
const fileName = `${jobName}.json`;
|
|
25
27
|
try {
|
|
26
28
|
// Move the specified report to the directory of hidden reports.
|
package/web/metrics/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// IMPORTS
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics,
|
|
10
|
+
clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, isValidAuthCode,
|
|
11
|
+
metricsExclusionCookieName, populateTemplate
|
|
11
12
|
} from '../../util.ts';
|
|
12
13
|
|
|
13
14
|
// FUNCTIONS
|
|
@@ -64,7 +65,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
64
65
|
// If the form has been submitted:
|
|
65
66
|
if (method === 'POST') {
|
|
66
67
|
// If the authorization code is invalid:
|
|
67
|
-
if (authCode
|
|
68
|
+
if (!isValidAuthCode(authCode)) {
|
|
68
69
|
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
69
70
|
// authorization code specifically (as opposed to some other part of the request) is
|
|
70
71
|
// what was wrong.
|
package/web/reannotate/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getReport,
|
|
12
12
|
getReportPath,
|
|
13
13
|
isReportError,
|
|
14
|
+
isValidAuthCode,
|
|
14
15
|
readdirOrCreate,
|
|
15
16
|
reportsPath
|
|
16
17
|
} from '../../util.ts';
|
|
@@ -22,7 +23,7 @@ import path from 'node:path';
|
|
|
22
23
|
// Implements a reannotation order and returns an acknowledgement page.
|
|
23
24
|
export const answer = async (authCode: string) => {
|
|
24
25
|
// If the authorization code is valid:
|
|
25
|
-
if (authCode
|
|
26
|
+
if (isValidAuthCode(authCode)) {
|
|
26
27
|
// Get the names of the stored report files.
|
|
27
28
|
const reportFileNames = (await readdirOrCreate(reportsPath(), 'Reports directory'))
|
|
28
29
|
.filter(fileName => fileName.endsWith('.json'));
|
package/web/renewWCAG/index.ts
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {getJSON} from '../../util.ts';
|
|
10
|
+
import {getJSON, isValidAuthCode} from '../../util.ts';
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
14
14
|
// Renews the WCAG map and serves an acknowledgment.
|
|
15
15
|
export const answer = async (authCode: string) => {
|
|
16
16
|
// If the authorization code is valid:
|
|
17
|
-
if (authCode
|
|
17
|
+
if (isValidAuthCode(authCode)) {
|
|
18
18
|
// Get the map source response.
|
|
19
19
|
const mapSourceResponse = await fetch('https://www.w3.org/WAI/WCAG22/Understanding/');
|
|
20
20
|
const mapSourceStatus = mapSourceResponse.status;
|
package/web/reportDeletion.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
errorMessage, getReportData, objectSort, populateTemplate, readdirOrCreate,
|
|
9
|
+
errorMessage, getReportData, isValidAuthCode, objectSort, populateTemplate, readdirOrCreate,
|
|
10
|
+
reportsPath
|
|
10
11
|
} from '../util.ts';
|
|
11
12
|
import fs from 'node:fs/promises';
|
|
12
13
|
import path from 'node:path';
|
|
@@ -48,7 +49,7 @@ export const reportDeletionForm = async (
|
|
|
48
49
|
// If the form has been submitted and any reports are to be deleted:
|
|
49
50
|
if (method === 'POST' && jobNames?.length) {
|
|
50
51
|
// If the authorization code is valid:
|
|
51
|
-
if (authCode
|
|
52
|
+
if (isValidAuthCode(authCode)) {
|
|
52
53
|
try {
|
|
53
54
|
// For each report to be deleted:
|
|
54
55
|
for (const jobName of jobNames) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
// IMPORTS
|
|
9
9
|
|
|
10
|
-
import {populateTemplate} from '../../util.ts';
|
|
10
|
+
import {isValidAuthCode, populateTemplate} from '../../util.ts';
|
|
11
11
|
import {answer as unhideReportForm} from '../unhideReportForm/index.ts';
|
|
12
12
|
|
|
13
13
|
// FUNCTIONS
|
|
@@ -22,7 +22,7 @@ export const answer = async (pathTail: any, search: string, method: string) => {
|
|
|
22
22
|
// If the form has been submitted:
|
|
23
23
|
if (method === 'POST') {
|
|
24
24
|
// If the authorization code is invalid:
|
|
25
|
-
if (authCode
|
|
25
|
+
if (!isValidAuthCode(authCode)) {
|
|
26
26
|
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
27
27
|
// authorization code specifically (as opposed to some other part of the request) is
|
|
28
28
|
// what was wrong.
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
hiddenReportsPath, isValidAuthCode, populateTemplate, readdirOrCreate, reportsPath
|
|
10
|
+
} from '../../util.ts';
|
|
9
11
|
import fs from 'node:fs/promises';
|
|
10
12
|
import path from 'node:path';
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
20
22
|
// If the form has been submitted and a report is to be unhidden:
|
|
21
23
|
if (method === 'POST' && jobName) {
|
|
22
24
|
// If the authorization code is valid:
|
|
23
|
-
if (authCode
|
|
25
|
+
if (isValidAuthCode(authCode)) {
|
|
24
26
|
const fileName = `${jobName}.json`;
|
|
25
27
|
try {
|
|
26
28
|
// Move the report to the reports directory.
|