@jrpool/kilotest 49.0.0 → 51.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/getReport.ts 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
@@ -7,6 +7,20 @@
7
7
 
8
8
  import {test, beforeEach, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ // Blank the alert configuration unconditionally, before ./requestFeature.ts (which
14
+ // calls sendAlert) is ever imported below, so this file sends no real alert emails
15
+ // even when run directly rather than via `npm test`.
16
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
17
+ process.env[key] = '';
18
+ }
19
+
20
+ // ENVIRONMENT
21
+
22
+ const testDir = path.join(import.meta.dirname, '../test/fixtures/featureRequests');
23
+ process.env.FEATURE_REQUESTS_PATH = path.join(testDir, 'featureRequests.json');
10
24
 
11
25
  // SETUP AND TEARDOWN
12
26
 
@@ -14,16 +28,19 @@ let logged: any[] = [];
14
28
  const originalLog = console.log;
15
29
 
16
30
  // Capture console.log so the alert sendAlert emits can be observed. The alert
17
- // configuration is empty in tests, so sendAlert logs a WARNING instead of sending.
18
- beforeEach(() => {
31
+ // configuration is blanked above, so sendAlert logs a WARNING instead of sending.
32
+ beforeEach(async () => {
19
33
  logged = [];
20
34
  console.log = (...args) => logged.push(args.join(' '));
35
+ await fs.mkdir(testDir, {recursive: true});
36
+ await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, '[]\n');
21
37
  });
22
38
 
23
39
  import {response} from './requestFeature.ts';
24
40
 
25
- after(() => {
41
+ after(async () => {
26
42
  console.log = originalLog;
43
+ await fs.rm(testDir, {recursive: true, force: true});
27
44
  });
28
45
 
29
46
  // TESTS
@@ -37,6 +54,24 @@ test('requestFeature rejects an empty feature request', async () => {
37
54
  );
38
55
  });
39
56
 
57
+ test('requestFeature rejects a feature request shorter than 20 characters', async () => {
58
+ const body = await response(['Too short']);
59
+ const details = body['response content']['details about your request'] as any;
60
+ assert.ok(details.error.includes('feature description'));
61
+ assert.ok(
62
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
63
+ );
64
+ });
65
+
66
+ test('requestFeature rejects a feature request longer than 1000 characters', async () => {
67
+ const body = await response(['x'.repeat(1001)]);
68
+ const details = body['response content']['details about your request'] as any;
69
+ assert.ok(details.error.includes('feature description'));
70
+ assert.ok(
71
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
72
+ );
73
+ });
74
+
40
75
  test('requestFeature accepts a non-empty feature request and notifies the manager', async () => {
41
76
  const body = await response(['Add a dark mode toggle']);
42
77
  const details = body['response content']['details about your request'] as any;
@@ -50,7 +85,68 @@ test('requestFeature accepts a non-empty feature request and notifies the manage
50
85
  });
51
86
 
52
87
  test('requestFeature includes tool name and metadata', async () => {
53
- const body = await response(['Some feature']);
88
+ const body = await response(['A sufficiently long feature description']);
54
89
  assert.equal(body['tool name'], 'requestFeature');
55
90
  assert.ok(body['response metadata'].identifier);
56
91
  });
92
+
93
+ test('requestFeature records a valid feature request to the feature-requests file', async () => {
94
+ await response(['Add a dark mode toggle to the reports page']);
95
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
96
+ assert.equal(featureRequests.length, 1);
97
+ assert.equal(featureRequests[0].content, 'Add a dark mode toggle to the reports page');
98
+ assert.ok(featureRequests[0].timeStamp);
99
+ });
100
+
101
+ test('requestFeature appends to, rather than overwrites, existing feature requests', async () => {
102
+ await response(['First feature request of at least 20 characters']);
103
+ await response(['Second feature request of at least 20 characters']);
104
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
105
+ assert.equal(featureRequests.length, 2);
106
+ });
107
+
108
+ test('requestFeature does not record an invalid feature request', async () => {
109
+ await response(['short']);
110
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
111
+ assert.equal(featureRequests.length, 0);
112
+ });
113
+
114
+ test('requestFeature creates the feature-requests file when it does not exist', async () => {
115
+ await fs.unlink(process.env.FEATURE_REQUESTS_PATH!);
116
+ await response(['A feature request when no file yet exists']);
117
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
118
+ assert.equal(featureRequests.length, 1);
119
+ assert.equal(featureRequests[0].content, 'A feature request when no file yet exists');
120
+ });
121
+
122
+ test('requestFeature rejects a feature request identical to one already on file', async () => {
123
+ const feature = 'Add a dark mode toggle to the reports page';
124
+ const firstBody = await response([feature]);
125
+ const firstDetails = firstBody['response content']['details about your request'] as any;
126
+ assert.equal(firstDetails.error, undefined);
127
+ const secondBody = await response([feature]);
128
+ const secondDetails = secondBody['response content']['details about your request'] as any;
129
+ assert.ok(secondDetails.error.includes('identical to one already submitted'));
130
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
131
+ // The duplicate was not recorded a second time.
132
+ assert.equal(featureRequests.length, 1);
133
+ });
134
+
135
+ test('requestFeature does not notify the manager of a duplicate feature request', async () => {
136
+ const feature = 'Add a dark mode toggle to the settings page';
137
+ await response([feature]);
138
+ logged = [];
139
+ await response([feature]);
140
+ assert.ok(
141
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
142
+ );
143
+ });
144
+
145
+ test('requestFeature accepts a feature request that differs from an existing one', async () => {
146
+ await response(['An existing feature request on file']);
147
+ const body = await response(['A different feature request entirely']);
148
+ const details = body['response content']['details about your request'] as any;
149
+ assert.equal(details.error, undefined);
150
+ const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
151
+ assert.equal(featureRequests.length, 2);
152
+ });
@@ -8,35 +8,79 @@
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
10
  import {sendAlert} from '../alerts.ts';
11
+ import {checkCommentDuplicate, checkLength, getJSON, getNowStamp} from '../util.ts';
11
12
  import {requestFeatureResponseSchema} from './schemas.ts';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
12
15
 
13
16
  // TYPES
14
17
 
15
18
  // The response content defined by the response schema.
16
19
  type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response content'];
17
20
 
21
+ // FUNCTIONS (helpers)
22
+
23
+ const getFeatureRequestsPath = () => process.env.FEATURE_REQUESTS_PATH || path.join(import.meta.dirname, '../db/featureRequests.json');
24
+
18
25
  // FUNCTIONS
19
26
 
20
27
  // Returns the response body.
21
28
  export const response = async (args: string[]) => {
22
29
  const [feature = ''] = args;
23
30
  const thisHost = getThisHost();
31
+ const lengthCheck = checkLength(feature, 20, 1000, 'feature description');
24
32
  // Initialize the response content.
25
- const responseContent: ResponseContent = {
26
- 'details about your request': {
27
- error: 'request invalid: request is empty'
28
- }
29
- };
30
- // If the requested feature or improvement exists:
31
- if (feature) {
32
- // Notify the manager.
33
- await sendAlert('Kilotest: MCP feature request received', feature);
34
- // Add the disposition to the response content.
35
- responseContent['details about your request'] = {
36
- 'date and time received': new Date().toISOString(),
37
- disposition: 'received and logged; manager notified'
33
+ let responseContent: ResponseContent;
34
+ // If the feature description is invalid:
35
+ if (lengthCheck.status === 'error') {
36
+ responseContent = {
37
+ 'details about your request': {
38
+ error: `request invalid: ${lengthCheck.message}`
39
+ }
38
40
  };
39
41
  }
42
+ // Otherwise, i.e. if it is valid:
43
+ else {
44
+ // Get the feature requests already on file.
45
+ const featureRequestsPath = getFeatureRequestsPath();
46
+ let featureRequests: {timeStamp: string; content: string}[] = [];
47
+ try {
48
+ const existing = await fs.readFile(featureRequestsPath, 'utf8');
49
+ featureRequests = JSON.parse(existing);
50
+ }
51
+ catch {
52
+ // Initialize an empty feature-requests array.
53
+ }
54
+ // If the request is identical to one already on file:
55
+ const duplicateCheck = checkCommentDuplicate(featureRequests, feature);
56
+ if (duplicateCheck.status === 'error') {
57
+ responseContent = {
58
+ 'details about your request': {
59
+ error: 'request invalid: your feature request is identical to one already submitted, ' +
60
+ 'but you are welcome to submit a different one'
61
+ }
62
+ };
63
+ }
64
+ // Otherwise, i.e. if it is not a duplicate:
65
+ else {
66
+ // Record the feature request alongside the existing ones.
67
+ featureRequests.push({
68
+ timeStamp: getNowStamp(),
69
+ content: feature
70
+ });
71
+ await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
72
+ await fs.writeFile(featureRequestsPath, getJSON(featureRequests));
73
+ // Notify the manager.
74
+ await sendAlert('Kilotest: MCP feature request received', feature);
75
+ // Add the disposition to the response content.
76
+ responseContent = {
77
+ 'details about your request': {
78
+ 'date and time received': new Date().toISOString(),
79
+ disposition: 'received and logged; manager notified'
80
+ }
81
+ };
82
+ }
83
+ }
40
84
  // Create a response body.
41
85
  const body = {
42
86
  'tool collection': getToolsFacts(),
@@ -11,15 +11,40 @@ import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
  import {fixtureDBDir} from '../test/dbFixture.ts';
13
13
 
14
+ // Blank the alert configuration unconditionally, before ./requestRetest.ts (whose
15
+ // processTestRequest calls sendAlert) is ever imported below, so this file sends no
16
+ // real alert emails even when run directly rather than via `npm test`.
17
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
18
+ process.env[key] = '';
19
+ }
20
+
14
21
  // SETUP AND TEARDOWN
15
22
 
16
23
  const savedDBDir = process.env.DB_DIR;
17
24
  const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
25
+ // A report added only to this test's disposable copy of the fixture database (never to
26
+ // the tracked test/fixtures/db corpus), so its 3-character job ID satisfies isJobID
27
+ // without perturbing report-count tests elsewhere that copy the whole corpus. Its job ID
28
+ // intentionally differs from the tracked corpus's 2-character 260101T0001-ct.json, which
29
+ // isJobID would reject.
30
+ const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0003-ret.json');
31
+ const retestFixtureReport = {
32
+ id: '260101T0003-ret',
33
+ what: 'Retest Fixture Page',
34
+ target: {what: 'Retest Fixture Page', url: 'https://example.com/retestfixture'},
35
+ sources: {worker: 'test-worker'},
36
+ acts: [],
37
+ jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
38
+ catalog: {},
39
+ images: {},
40
+ checkpoints: []
41
+ };
18
42
  let logged: any[] = [];
19
43
  const originalLog = console.log;
20
44
 
21
- before(() => {
45
+ before(async () => {
22
46
  process.env.DB_DIR = fixtureDBDir;
47
+ await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
23
48
  });
24
49
 
25
50
  // Reset the test-requests file and capture console.log, so the alert that
@@ -32,8 +57,9 @@ beforeEach(async () => {
32
57
 
33
58
  import {response} from './requestRetest.ts';
34
59
 
35
- after(() => {
60
+ after(async () => {
36
61
  console.log = originalLog;
62
+ await fs.unlink(retestFixturePath);
37
63
  if (savedDBDir !== undefined) {
38
64
  process.env.DB_DIR = savedDBDir;
39
65
  }
@@ -45,12 +71,19 @@ after(() => {
45
71
  // TESTS
46
72
 
47
73
  test('requestRetest rejects a nonexistent report', async () => {
48
- const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
74
+ const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
49
75
  const details = body['response content']['details about your request'] as any;
50
76
  assert.ok(details.error.includes('does not exist'));
51
77
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
52
78
  });
53
79
 
80
+ test('requestRetest rejects a malformed timestamp or job identifier', async () => {
81
+ const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
82
+ const details = body['response content']['details about your request'] as any;
83
+ assert.ok(details.error.includes('malformed'));
84
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
85
+ });
86
+
54
87
  test('requestRetest rejects a superseded report', async () => {
55
88
  // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
56
89
  // so it is superseded and processTestRequest returns 'superseded', reported via the
@@ -66,7 +99,7 @@ test('requestRetest rejects a superseded report', async () => {
66
99
  });
67
100
 
68
101
  test('requestRetest rejects a reason shorter than 20 characters', async () => {
69
- const body = await response(['260101T0001', 'ct', 'short']);
102
+ const body = await response(['260101T0003', 'ret', 'short']);
70
103
  const details = body['response content']['details about your request'] as any;
71
104
  assert.ok(details.error.includes('reason'));
72
105
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
@@ -74,20 +107,20 @@ test('requestRetest rejects a reason shorter than 20 characters', async () => {
74
107
 
75
108
  test('requestRetest rejects a reason longer than 100 characters', async () => {
76
109
  const longReason = 'x'.repeat(101);
77
- const body = await response(['260101T0001', 'ct', longReason]);
110
+ const body = await response(['260101T0003', 'ret', longReason]);
78
111
  const details = body['response content']['details about your request'] as any;
79
112
  assert.ok(details.error.includes('reason'));
80
113
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
81
114
  });
82
115
 
83
116
  test('requestRetest rejects a report matching a claimed job by description', async () => {
84
- // 260101T0001-ct is the "All CantTell Page" report.
117
+ // 260101T0003-ret is the "Retest Fixture Page" report.
85
118
  const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
86
119
  await fs.writeFile(claimedPath, JSON.stringify({
87
- target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
120
+ target: {what: 'Retest Fixture Page', url: 'https://example.com/unrelated'}
88
121
  }));
89
122
  try {
90
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
123
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
91
124
  const details = body['response content']['details about your request'] as any;
92
125
  assert.equal(details.error, undefined);
93
126
  const disposition = body['response content']['disposition of your request'] as any;
@@ -102,13 +135,13 @@ test('requestRetest rejects a report matching a claimed job by description', asy
102
135
  });
103
136
 
104
137
  test('requestRetest rejects a report matching a queued job by URL', async () => {
105
- // 260101T0001-ct's URL is https://example.com/canttell.
138
+ // 260101T0003-ret's URL is https://example.com/retestfixture.
106
139
  const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
107
140
  await fs.writeFile(queuedPath, JSON.stringify({
108
- target: {what: 'Some Other Page', url: 'https://example.com/canttell'}
141
+ target: {what: 'Some Other Page', url: 'https://example.com/retestfixture'}
109
142
  }));
110
143
  try {
111
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
144
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
112
145
  const details = body['response content']['details about your request'] as any;
113
146
  assert.equal(details.error, undefined);
114
147
  const disposition = body['response content']['disposition of your request'] as any;
@@ -123,8 +156,8 @@ test('requestRetest rejects a report matching a queued job by URL', async () =>
123
156
  });
124
157
 
125
158
  test('requestRetest rejects a duplicate request', async () => {
126
- await response(['260101T0001', 'ct', 'A reason that is long enough.']);
127
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
159
+ await response(['260101T0003', 'ret', 'A reason that is long enough.']);
160
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
128
161
  const details = body['response content']['details about your request'] as any;
129
162
  assert.equal(details.error, undefined);
130
163
  const disposition = body['response content']['disposition of your request'] as any;
@@ -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 reasonLength = reason.length;
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 encoded reason is too short or too long:
29
- if (reasonLength < 20 || reasonLength > 100) {
28
+ // If the timestamp or job ID is not syntactically valid:
29
+ if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
30
30
  requestDetails = {
31
- error: 'request invalid: your reason is not between 20 and 100 characters long'
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:
@@ -11,6 +11,13 @@ import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
  import {fixtureDBDir} from '../test/dbFixture.ts';
13
13
 
14
+ // Blank the alert configuration unconditionally, before ./requestTest.ts (whose
15
+ // processTestRequest calls sendAlert) is ever imported below, so this file sends no
16
+ // real alert emails even when run directly rather than via `npm test`.
17
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
18
+ process.env[key] = '';
19
+ }
20
+
14
21
  // SETUP AND TEARDOWN
15
22
 
16
23
  const savedDBDir = process.env.DB_DIR;
@@ -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 descriptionLength = description.length;
28
- // If the description is empty or too long:
29
- if (!descriptionLength || descriptionLength > 100) {
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: 'request invalid: your description of the page is not between 1 and 100 characters long'
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('10- to 100-character description of the page conforming to the naming convention used in the listReports output'),
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/api/util.test.ts CHANGED
@@ -7,6 +7,15 @@
7
7
 
8
8
  import {test} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
+
11
+ // Blank the alert configuration unconditionally, before ../util.ts (which
12
+ // processTestRequest calls sendAlert from) is ever imported below, so this file sends
13
+ // no real alert emails even when run directly (e.g. `npx tsx --test api/util.test.ts`)
14
+ // rather than via `npm test`, which normally guards against this via test/setup.ts.
15
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
16
+ process.env[key] = '';
17
+ }
18
+
10
19
  import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts} from './util.ts';
11
20
  import {processTestRequest} from '../util.ts';
12
21
 
@@ -8,19 +8,29 @@
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import http from 'node:http';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
11
13
  import {requestHandler} from './index.ts';
12
14
 
13
15
  // CONSTANTS
14
16
 
15
17
  const port = 3998;
18
+ const featureRequestsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/featureRequests.json');
16
19
 
17
20
  // SETUP AND TEARDOWN
18
21
 
19
22
  const savedDBDir = process.env.DB_DIR;
23
+ const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
20
24
  let server: http.Server;
21
25
 
22
26
  before(async () => {
23
27
  process.env.DB_DIR = (await import('./test/dbFixture.ts')).fixtureDBDir;
28
+ // Use a fixture path, rather than the real default, for feature requests, so this
29
+ // test's submission neither pollutes real data nor is rejected as a duplicate of
30
+ // whatever a prior run of this same test already left on disk.
31
+ process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
32
+ await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
33
+ await fs.writeFile(featureRequestsPath, '[]\n');
24
34
  server = http.createServer(requestHandler);
25
35
  await new Promise<void>(resolve => server.listen(port, () => resolve()));
26
36
  });
@@ -32,6 +42,13 @@ after(async () => {
32
42
  else {
33
43
  delete process.env.DB_DIR;
34
44
  }
45
+ if (savedFeatureRequestsPath !== undefined) {
46
+ process.env.FEATURE_REQUESTS_PATH = savedFeatureRequestsPath;
47
+ }
48
+ else {
49
+ delete process.env.FEATURE_REQUESTS_PATH;
50
+ }
51
+ await fs.rm(path.dirname(featureRequestsPath), {recursive: true, force: true});
35
52
  server.closeAllConnections?.();
36
53
  await new Promise<void>(resolve => {
37
54
  const timer = setTimeout(() => {
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