@jrpool/kilotest 48.0.0 → 49.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.
Files changed (49) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/requestFeature.test.ts +4 -2
  3. package/api/requestFeature.ts +1 -1
  4. package/docs/BACKLOG.md +220 -12
  5. package/docs/SERVICE.md +52 -1
  6. package/index.test.ts +334 -13
  7. package/index.ts +174 -10
  8. package/linkValidation.test.ts +1 -0
  9. package/mcp.test.ts +30 -0
  10. package/mcp.ts +9 -0
  11. package/package.json +1 -1
  12. package/smokeTest.ts +16 -0
  13. package/test/dbFixture.ts +8 -0
  14. package/util.test.ts +231 -0
  15. package/util.ts +97 -0
  16. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  17. package/web/ai0BalanceForm/index.html +1 -1
  18. package/web/ai0BalanceForm/index.ts +10 -6
  19. package/web/enqueue/index.ts +5 -2
  20. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  21. package/web/expungeReportsForm/index.html +1 -1
  22. package/web/expungeReportsForm/index.ts +1 -1
  23. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  24. package/web/hideReportForm/index.html +1 -1
  25. package/web/hideReportForm/index.ts +9 -6
  26. package/web/listTopIssues/index.ts +1 -1
  27. package/web/manage/index.html +2 -1
  28. package/web/metrics/index.html +19 -0
  29. package/web/metrics/index.ts +126 -0
  30. package/web/metrics/metrics.test.ts +211 -0
  31. package/web/pruneReportsForm/index.html +1 -1
  32. package/web/pruneReportsForm/index.ts +1 -1
  33. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  34. package/web/reannotate/index.ts +5 -2
  35. package/web/reannotate/reannotate.test.ts +1 -1
  36. package/web/renewWCAG/index.ts +4 -1
  37. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  38. package/web/reportDeletion.ts +10 -5
  39. package/web/rewindReportsForm/index.html +1 -1
  40. package/web/rewindReportsForm/index.ts +1 -1
  41. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  42. package/web/showHiddenReportsForm/index.html +24 -0
  43. package/web/showHiddenReportsForm/index.ts +45 -0
  44. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  45. package/web/tutorialAI/index.ts +1 -1
  46. package/web/tutorialWeb/index.ts +1 -1
  47. package/web/unhideReportForm/index.html +1 -1
  48. package/web/unhideReportForm/index.ts +9 -6
  49. package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
@@ -35,32 +35,40 @@ after(async () => {
35
35
 
36
36
  // TESTS
37
37
 
38
- test('rewindReportsForm displays a list of reports when no submission', async () => {
39
- const result = await answer(null, '');
38
+ test('rewindReportsForm displays a list of reports on a GET request', async () => {
39
+ const result = await answer(null, '', 'GET');
40
40
  assert.equal(result.status, 'ok');
41
41
  assert.ok(result.answerPage);
42
42
  const html = parse(result.answerPage);
43
43
  assert.ok(html.querySelector('title'));
44
44
  });
45
45
 
46
- test('rewindReportsForm returns an error for an invalid auth code', async () => {
47
- const result = await answer(null, 'authCode=wrong&report=260202T0000-new');
46
+ test('rewindReportsForm ignores a report query string on a GET request', async () => {
47
+ const reportPath = path.join(reportsPath(), '260202T0000-new.json');
48
+ const result = await answer(null, 'authCode=test-auth-code&report=260202T0000-new', 'GET');
49
+ assert.equal(result.status, 'ok');
50
+ // The report should NOT have been deleted by a mere GET request.
51
+ await fs.access(reportPath);
52
+ });
53
+
54
+ test('rewindReportsForm returns an error for an invalid auth code on a POST request', async () => {
55
+ const result = await answer(null, 'authCode=wrong&report=260202T0000-new', 'POST');
48
56
  assert.equal(result.status, 'error');
49
- assert.equal(result.message, 'Invalid authorization code');
57
+ assert.equal(result.message, 'Invalid request');
50
58
  });
51
59
 
52
- test('rewindReportsForm returns an error when deleting a nonexistent report', async () => {
53
- const result = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
60
+ test('rewindReportsForm returns an error when deleting a nonexistent report on a POST request', async () => {
61
+ const result = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
54
62
  assert.equal(result.status, 'error');
55
63
  assert.ok(result.message?.includes('Deleting latest superseding reports'));
56
64
  });
57
65
 
58
- test('rewindReportsForm deletes a latest superseding report with valid auth code', async () => {
66
+ test('rewindReportsForm deletes a latest superseding report with valid auth code on a POST request', async () => {
59
67
  // 260202T0000-new is the latest report for its URL, superseding 260101T0000-mix.
60
68
  const reportPath = path.join(reportsPath(), '260202T0000-new.json');
61
69
  const backup = await fs.readFile(reportPath, 'utf8');
62
70
  try {
63
- const result = await answer(null, 'authCode=test-auth-code&report=260202T0000-new');
71
+ const result = await answer(null, 'authCode=test-auth-code&report=260202T0000-new', 'POST');
64
72
  assert.equal(result.status, 'ok');
65
73
  await fs.access(reportPath).then(
66
74
  () => {throw new Error('Report should have been deleted');},
@@ -77,7 +85,7 @@ test('rewindReportsForm returns an error when a report file is corrupt', {timeou
77
85
  const backup = await fs.readFile(reportPath, 'utf8');
78
86
  try {
79
87
  await fs.writeFile(reportPath, 'not valid json');
80
- const result = await answer(null, '');
88
+ const result = await answer(null, '', 'GET');
81
89
  assert.equal(result.status, 'error');
82
90
  }
83
91
  finally {
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="publisher" content="Jonathan Robert Pool">
7
+ <meta name="creator" content="Jonathan Robert Pool">
8
+ <meta name="keywords" content="report,accessibility,a11y">
9
+ <title>View experimental reports | Kilotest</title>
10
+ <link rel="icon" href="/favicon.ico">
11
+ <link rel="stylesheet" href="/style.css">
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <h1><a href="/">Kilotest</a>: View experimental reports</h1>
16
+ <form action="/showHiddenReportsForm.html" method="post">
17
+ <p><label>
18
+ Authorization code: <input size="3" minLength="3" maxlength="3" name="authCode" required>
19
+ </label></p>
20
+ <p><button type="submit">Submit</button></p>
21
+ </form>
22
+ </main>
23
+ </body>
24
+ </html>
@@ -0,0 +1,45 @@
1
+ /*
2
+ index.ts
3
+ Serves a form requesting an authorization code, and, once a valid code is submitted,
4
+ serves unhideReportForm's own list of hidden reports and its self-submitting unhide
5
+ form, so that the names of hidden reports are never disclosed without a valid code.
6
+ */
7
+
8
+ // IMPORTS
9
+
10
+ import {populateTemplate} from '../../util.ts';
11
+ import {answer as unhideReportForm} from '../unhideReportForm/index.ts';
12
+
13
+ // FUNCTIONS
14
+
15
+ // Returns a form requesting an authorization code on a GET request or an invalid
16
+ // submission, or, on a POST request with a valid code, unhideReportForm's own page
17
+ // (the list of hidden reports and its unhide form). A GET request, regardless of its
18
+ // query string, never processes a submission, so the code cannot be validated by GET.
19
+ export const answer = async (pathTail: any, search: string, method: string) => {
20
+ const searchParams = new URLSearchParams(search);
21
+ const authCode = searchParams?.get('authCode');
22
+ // If the form has been submitted:
23
+ if (method === 'POST') {
24
+ // If the authorization code is invalid:
25
+ if (authCode !== process.env.AUTH_CODE) {
26
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
27
+ // authorization code specifically (as opposed to some other part of the request) is
28
+ // what was wrong.
29
+ return {
30
+ status: 'error',
31
+ message: 'Invalid request'
32
+ };
33
+ }
34
+ // Otherwise, i.e. if the authorization code is valid, serve unhideReportForm's own
35
+ // page (the list of hidden reports and its self-submitting unhide form).
36
+ return unhideReportForm(pathTail, search, 'GET');
37
+ }
38
+ // Otherwise, i.e. if the form has not been submitted yet, get the populated template.
39
+ const answerPage = await populateTemplate(import.meta.dirname, {});
40
+ // Return the populated page.
41
+ return {
42
+ status: 'ok',
43
+ answerPage
44
+ };
45
+ };
@@ -0,0 +1,103 @@
1
+ /*
2
+ showHiddenReportsForm.test.ts
3
+ Unit tests for web/showHiddenReportsForm/index.ts.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after, beforeEach} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import path from 'node:path';
11
+ import fs from 'fs/promises';
12
+ import {parse} from 'node-html-parser';
13
+ import {answer} from './index.ts';
14
+ import {reportsPath, hiddenReportsPath} from '../../util.ts';
15
+
16
+ // CONSTANTS
17
+
18
+ const hiddenFile = '260101T0007-hid.json';
19
+
20
+ // SETUP AND TEARDOWN
21
+
22
+ const savedAuthCode = process.env.AUTH_CODE;
23
+ const savedDBDir = process.env.DB_DIR;
24
+ import {fixtureDBDir} from '../../test/dbFixture.ts';
25
+
26
+ const getPaths = () => ({
27
+ hidden: path.join(hiddenReportsPath(), hiddenFile),
28
+ report: path.join(reportsPath(), hiddenFile)
29
+ });
30
+
31
+ before(() => {
32
+ process.env.AUTH_CODE = 'test-auth-code';
33
+ process.env.DB_DIR = fixtureDBDir;
34
+ });
35
+
36
+ beforeEach(async () => {
37
+ // Ensure the hidden report is in the hidden directory before each test.
38
+ const {hidden, report} = getPaths();
39
+ try {
40
+ await fs.rename(report, hidden);
41
+ }
42
+ catch {
43
+ // Report may already be hidden.
44
+ }
45
+ });
46
+
47
+ after(async () => {
48
+ // Restore the hidden report if it was moved.
49
+ const {hidden, report} = getPaths();
50
+ try {
51
+ await fs.rename(report, hidden);
52
+ }
53
+ catch {
54
+ // Report may already be hidden.
55
+ }
56
+ if (savedAuthCode !== undefined) {
57
+ process.env.AUTH_CODE = savedAuthCode;
58
+ }
59
+ if (savedDBDir !== undefined) {
60
+ process.env.DB_DIR = savedDBDir;
61
+ }
62
+ });
63
+
64
+ // TESTS
65
+
66
+ test('showHiddenReportsForm displays a bare authCode form on a GET request, disclosing nothing', async () => {
67
+ const result: any = await answer(null, '', 'GET');
68
+ assert.equal(result.status, 'ok');
69
+ const html = parse(result.answerPage);
70
+ assert.ok(html.querySelector('input[name="authCode"]'));
71
+ assert.equal(html.querySelectorAll('input[type="radio"]').length, 0);
72
+ assert.ok(!result.answerPage.includes('260101T0007-hid'));
73
+ });
74
+
75
+ test('showHiddenReportsForm submits its form as a POST', async () => {
76
+ const result: any = await answer(null, '', 'GET');
77
+ const html = parse(result.answerPage);
78
+ const form = html.querySelector('form');
79
+ assert.equal(form?.getAttribute('method'), 'post');
80
+ });
81
+
82
+ test('showHiddenReportsForm ignores a valid authCode query string on a GET request', async () => {
83
+ const result: any = await answer(null, 'authCode=test-auth-code', 'GET');
84
+ assert.equal(result.status, 'ok');
85
+ const html = parse(result.answerPage);
86
+ assert.ok(html.querySelector('input[name="authCode"]'));
87
+ assert.equal(html.querySelectorAll('input[type="radio"]').length, 0);
88
+ });
89
+
90
+ test('showHiddenReportsForm returns an error for an invalid auth code on a POST request', async () => {
91
+ const result: any = await answer(null, 'authCode=wrong', 'POST');
92
+ assert.equal(result.status, 'error');
93
+ assert.equal(result.message, 'Invalid request');
94
+ });
95
+
96
+ test('showHiddenReportsForm serves the hidden reports list on a POST request with a valid auth code', async () => {
97
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
98
+ assert.equal(result.status, 'ok');
99
+ const html = parse(result.answerPage);
100
+ const radios = html.querySelectorAll('input[type="radio"]');
101
+ assert.ok(radios.length > 0);
102
+ assert.ok(result.answerPage.includes('260101T0007-hid'));
103
+ });
@@ -75,6 +75,6 @@ export const handleComment = async (content: unknown) => {
75
75
  // Save the revised comments.
76
76
  await fs.writeFile(commentsPath, getJSON(comments));
77
77
  // Send an alert to the manager.
78
- await sendAlert('New AI tutorial comment received', 'A new AI tutorial comment has been received.');
78
+ await sendAlert('Kilotest: New AI tutorial comment received', 'A new AI tutorial comment has been received.');
79
79
  return {status: 'ok'};
80
80
  };
@@ -73,6 +73,6 @@ export const handleComment = async (content: unknown) => {
73
73
  // Save the revised comments.
74
74
  await fs.writeFile(commentsPath, getJSON(comments));
75
75
  // Send an alert to the manager.
76
- await sendAlert('New web tutorial comment received', 'A new web tutorial comment has been received.');
76
+ await sendAlert('Kilotest: New web tutorial comment received', 'A new web tutorial comment has been received.');
77
77
  return {status: 'ok'};
78
78
  };
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Declassify report as experimental</h1>
16
16
  <p>Choose a report to declassify as experimental.</p>
17
- <form action="/unhideReportForm.html">
17
+ <form action="/unhideReportForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -11,13 +11,14 @@ import path from 'node:path';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
- // Returns a form for unhiding a report.
15
- export const answer = async (_: any, search: string) => {
14
+ // Returns a form for unhiding a report. A GET request never processes a submission,
15
+ // regardless of its query string; only a POST request (the form's own submission) does.
16
+ export const answer = async (_: any, search: string, method: string) => {
16
17
  const searchParams = new URLSearchParams(search);
17
18
  const authCode = searchParams?.get('authCode');
18
19
  const jobName = searchParams?.get('report');
19
- // If the form has been displayed by itself after a submission and a report is to be unhidden:
20
- if (jobName) {
20
+ // If the form has been submitted and a report is to be unhidden:
21
+ if (method === 'POST' && jobName) {
21
22
  // If the authorization code is valid:
22
23
  if (authCode === process.env.AUTH_CODE) {
23
24
  const fileName = `${jobName}.json`;
@@ -35,10 +36,12 @@ export const answer = async (_: any, search: string) => {
35
36
  }
36
37
  // Otherwise, i.e. if the authorization code is invalid:
37
38
  else {
38
- // Report the error.
39
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
40
+ // authorization code specifically (as opposed to some other part of the request) is
41
+ // what was wrong.
39
42
  return {
40
43
  status: 'error',
41
- message: 'Invalid authorization code'
44
+ message: 'Invalid request'
42
45
  }
43
46
  }
44
47
  }
@@ -63,8 +63,8 @@ after(async () => {
63
63
 
64
64
  // TESTS
65
65
 
66
- test('unhideReportForm displays a list of hidden reports when no submission', async () => {
67
- const result: any = await answer(null, '');
66
+ test('unhideReportForm displays a list of hidden reports on a GET request', async () => {
67
+ const result: any = await answer(null, '', 'GET');
68
68
  assert.equal(result.status, 'ok');
69
69
  assert.ok(result.answerPage);
70
70
  const html = parse(result.answerPage);
@@ -72,22 +72,30 @@ test('unhideReportForm displays a list of hidden reports when no submission', as
72
72
  assert.ok(radios.length > 0);
73
73
  });
74
74
 
75
- test('unhideReportForm returns an error for an invalid auth code', async () => {
76
- const result: any = await answer(null, 'authCode=wrong&report=260101T0007-hid');
75
+ test('unhideReportForm ignores a report query string on a GET request', async () => {
76
+ const {report} = getPaths();
77
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0007-hid', 'GET');
78
+ assert.equal(result.status, 'ok');
79
+ // The report should NOT have been unhidden by a mere GET request.
80
+ await assert.rejects(fs.access(report));
81
+ });
82
+
83
+ test('unhideReportForm returns an error for an invalid auth code on a POST request', async () => {
84
+ const result: any = await answer(null, 'authCode=wrong&report=260101T0007-hid', 'POST');
77
85
  assert.equal(result.status, 'error');
78
- assert.equal(result.message, 'Invalid authorization code');
86
+ assert.equal(result.message, 'Invalid request');
79
87
  });
80
88
 
81
- test('unhideReportForm unhides a report with valid auth code', async () => {
89
+ test('unhideReportForm unhides a report with valid auth code on a POST request', async () => {
82
90
  const {report} = getPaths();
83
- const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0007-hid');
91
+ const result: any = await answer(null, 'authCode=test-auth-code&report=260101T0007-hid', 'POST');
84
92
  assert.equal(result.status, 'ok');
85
93
  // The report should now be in the reports directory.
86
94
  await fs.access(report);
87
95
  });
88
96
 
89
- test('unhideReportForm returns an error when unhiding a nonexistent report', async () => {
90
- const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
97
+ test('unhideReportForm returns an error when unhiding a nonexistent report on a POST request', async () => {
98
+ const result: any = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
91
99
  assert.equal(result.status, 'error');
92
100
  assert.ok(result.message.includes('Unhiding report'));
93
101
  });
@@ -102,7 +110,7 @@ test('unhideReportForm sorts hidden reports with the same page name by timeStamp
102
110
  const copyPath = path.join(hiddenReportsPath(), '260101T0010-hid2.json');
103
111
  await fs.writeFile(copyPath, JSON.stringify(copy));
104
112
  try {
105
- const result: any = await answer(null, '');
113
+ const result: any = await answer(null, '', 'GET');
106
114
  assert.equal(result.status, 'ok');
107
115
  // Both reports should appear in the form.
108
116
  assert.ok(result.answerPage.includes('260101T0007-hid'));
@@ -123,7 +131,7 @@ test('unhideReportForm sorts hidden reports with different page names alphabetic
123
131
  const copyPath = path.join(hiddenReportsPath(), '260101T0011-hid3.json');
124
132
  await fs.writeFile(copyPath, JSON.stringify(copy));
125
133
  try {
126
- const result: any = await answer(null, '');
134
+ const result: any = await answer(null, '', 'GET');
127
135
  assert.equal(result.status, 'ok');
128
136
  // Both reports should appear in the form.
129
137
  assert.ok(result.answerPage.includes('260101T0007-hid'));