@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
@@ -11,13 +11,14 @@ import path from 'node:path';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
- // Returns a form for hiding a report.
15
- export const answer = async (_: any, search: string) => {
14
+ // Returns a form for hiding 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 hidden:
20
- if (jobName) {
20
+ // If the form has been submitted and a report is to be hidden:
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`;
@@ -36,10 +37,12 @@ export const answer = async (_: any, search: string) => {
36
37
  }
37
38
  // Otherwise, i.e. if the authorization code is invalid:
38
39
  else {
39
- // Report the error.
40
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
41
+ // authorization code specifically (as opposed to some other part of the request) is
42
+ // what was wrong.
40
43
  return {
41
44
  status: 'error',
42
- message: 'Invalid authorization code'
45
+ message: 'Invalid request'
43
46
  }
44
47
  }
45
48
  }
@@ -79,7 +79,7 @@ const getIssuesSummary = async () => {
79
79
  // Report this.
80
80
  console.log(`ERROR: Annotations obsolete for issue ${issueID}; reannotate`);
81
81
  // Notify a manager.
82
- sendAlert('Annotations obsolete', `Annotations for issue ${issueID} obsolete; reannotate.`);
82
+ sendAlert('Kilotest: Annotations obsolete', `Annotations for issue ${issueID} obsolete; reannotate.`);
83
83
  }
84
84
  });
85
85
  // For each summarized issue:
@@ -23,9 +23,10 @@
23
23
  <li><a href="/rewindReportsForm.html">Delete latest superseding reports</a></li>
24
24
  <li><a href="/expungeReportsForm.html">Delete sole reports</a></li>
25
25
  <li><a href="/hideReportForm.html">Classify a report as experimental</a></li>
26
- <li><a href="/unhideReportForm.html">Declassify a report as experimental</a></li>
26
+ <li><a href="/showHiddenReportsForm.html">View the list of experimental reports</a></li>
27
27
  <li><a href="/ai0BalanceForm.html">Record the current balance of AI service 0</a></li>
28
28
  <li><a href="/renewWCAGForm.html">Renew the map of WCAG numeric to text identifiers</a></li>
29
+ <li><a href="/metrics.html">View usage metrics</a></li>
29
30
  </ul>
30
31
  </main>
31
32
  </body>
@@ -0,0 +1,19 @@
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>Usage metrics | 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>: Usage metrics</h1>
16
+ __body__
17
+ </main>
18
+ </body>
19
+ </html>
@@ -0,0 +1,126 @@
1
+ /*
2
+ index.ts
3
+ Serves usage metrics for the maintainer: web page views, MCP tool calls, and API
4
+ operation calls, each broken down by name, recorded since a given date and time.
5
+ */
6
+
7
+ // IMPORTS
8
+
9
+ import {
10
+ clearMetrics, getDateTimeString, getExclusionCookieValue, getMetrics, metricsExclusionCookieName, populateTemplate
11
+ } from '../../util.ts';
12
+
13
+ // FUNCTIONS
14
+
15
+ // Returns an HTML table of the counts in a metrics category, sorted by descending count.
16
+ const getCategoryTable = (counts: Record<string, number>): string => {
17
+ const margin = ' '.repeat(6);
18
+ const names = Object.keys(counts).sort((a, b) => counts[b]! - counts[a]!);
19
+ if (!names.length) {
20
+ return `${margin}<p>None yet.</p>`;
21
+ }
22
+ const rows = names.map(name => `${margin} <tr><td>${name}</td><td>${counts[name]}</td></tr>`);
23
+ return [
24
+ `${margin}<table class="allBorder">`,
25
+ `${margin} <thead>`,
26
+ `${margin} <tr><th>Name</th><th>Count</th></tr>`,
27
+ `${margin} </thead>`,
28
+ `${margin} <tbody>`,
29
+ ...rows,
30
+ `${margin} </tbody>`,
31
+ `${margin}</table>`
32
+ ].join('\n');
33
+ };
34
+ // Returns an HTML table of manager-page activity, sorted by descending failure count,
35
+ // since repeated failures are the signal of suspected abuse most worth surfacing first.
36
+ const getManagerActivityTable = (activity: Record<string, {ok: number; error: number}>): string => {
37
+ const margin = ' '.repeat(6);
38
+ const names = Object.keys(activity).sort((a, b) => activity[b]!.error - activity[a]!.error);
39
+ if (!names.length) {
40
+ return `${margin}<p>None yet.</p>`;
41
+ }
42
+ const rows = names.map(name => {
43
+ const {ok, error} = activity[name]!;
44
+ return `${margin} <tr><td>${name}</td><td>${ok}</td><td>${error}</td></tr>`;
45
+ });
46
+ return [
47
+ `${margin}<table class="allBorder">`,
48
+ `${margin} <thead>`,
49
+ `${margin} <tr><th>Name</th><th>Successful</th><th>Failed</th></tr>`,
50
+ `${margin} </thead>`,
51
+ `${margin} <tbody>`,
52
+ ...rows,
53
+ `${margin} </tbody>`,
54
+ `${margin}</table>`
55
+ ].join('\n');
56
+ };
57
+ // Returns a page reporting usage metrics, or, if the form has not been submitted (a POST
58
+ // request) yet, a form requesting an authorization code. A GET request, regardless of its
59
+ // query string, never processes a submission, so the action cannot be triggered by GET.
60
+ export const answer = async (_: any, search: string, method: string) => {
61
+ const searchParams = new URLSearchParams(search);
62
+ const authCode = searchParams?.get('authCode');
63
+ let query: Record<string, string>;
64
+ // If the form has been submitted:
65
+ if (method === 'POST') {
66
+ // If the authorization code is invalid:
67
+ if (authCode !== process.env.AUTH_CODE) {
68
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
69
+ // authorization code specifically (as opposed to some other part of the request) is
70
+ // what was wrong.
71
+ return {
72
+ status: 'error',
73
+ message: 'Invalid request'
74
+ };
75
+ }
76
+ // If clearing the counts was requested:
77
+ const clearCounts = searchParams.has('clearCounts');
78
+ const metrics = clearCounts ? await clearMetrics() : await getMetrics();
79
+ query = {
80
+ body: [
81
+ ...(clearCounts ? ['<p>Counts cleared.</p>'] : []),
82
+ `<p>Counts recorded since ${getDateTimeString(metrics.since)}.</p>`,
83
+ '<h2>Web page views</h2>',
84
+ getCategoryTable(metrics.pageViews),
85
+ '<h2>MCP tool calls</h2>',
86
+ getCategoryTable(metrics.mcpToolCalls),
87
+ '<h2>API operation calls</h2>',
88
+ getCategoryTable(metrics.apiOperations),
89
+ '<h2>Manager page activity</h2>',
90
+ '<p>Manager-only pages (linked from <a href="/manage.html">management</a>), excluded from the web page views above. A high failed count for a page may indicate a suspected attack, such as repeated guessing of the authorization code.</p>',
91
+ getManagerActivityTable(metrics.managerActivity)
92
+ ].join('\n')
93
+ };
94
+ }
95
+ // Otherwise, i.e. if the form has not been submitted yet:
96
+ else {
97
+ query = {
98
+ body: [
99
+ '<form action="/metrics.html" method="post">',
100
+ ' <p><label>',
101
+ ' Authorization code: <input size="3" minlength="3" maxlength="3" name="authCode" required>',
102
+ ' </label></p>',
103
+ ' <p><label><input type="checkbox" name="clearCounts"> Clear counts</label></p>',
104
+ ' <p><button type="submit">Submit</button></p>',
105
+ '</form>'
106
+ ].join('\n')
107
+ };
108
+ }
109
+ // Get the populated template.
110
+ const answerPage = await populateTemplate(import.meta.dirname, query);
111
+ // Return the populated page. Submitting a valid authCode also sets a cookie that
112
+ // excludes this browser's future page views and API calls from usage metrics, on the
113
+ // premise that a maintainer who has just proven their identity here is very likely the
114
+ // same person about to manually browse and test the rest of Kilotest.
115
+ return {
116
+ status: 'ok',
117
+ answerPage,
118
+ ...(method === 'POST' && {
119
+ setCookie: {
120
+ name: metricsExclusionCookieName,
121
+ value: getExclusionCookieValue(),
122
+ maxAgeSeconds: 30 * 24 * 60 * 60
123
+ }
124
+ })
125
+ };
126
+ };
@@ -0,0 +1,211 @@
1
+ /*
2
+ metrics.test.ts
3
+ Unit tests for web/metrics/index.ts.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import {parse} from 'node-html-parser';
12
+ import {answer} from './index.ts';
13
+ import {getJSON, metricsPath} from '../../util.ts';
14
+
15
+ // SETUP AND TEARDOWN
16
+
17
+ const savedAuthCode = process.env.AUTH_CODE;
18
+ const savedDBDir = process.env.DB_DIR;
19
+ import {fixtureDBDir} from '../../test/dbFixture.ts';
20
+
21
+ before(() => {
22
+ process.env.AUTH_CODE = 'test-auth-code';
23
+ process.env.DB_DIR = fixtureDBDir;
24
+ });
25
+
26
+ after(() => {
27
+ if (savedAuthCode !== undefined) {
28
+ process.env.AUTH_CODE = savedAuthCode;
29
+ }
30
+ if (savedDBDir !== undefined) {
31
+ process.env.DB_DIR = savedDBDir;
32
+ }
33
+ });
34
+
35
+ // TESTS
36
+
37
+ test('metrics returns an error for an invalid auth code on a POST request', async () => {
38
+ const result: any = await answer(null, 'authCode=wrong', 'POST');
39
+ assert.equal(result.status, 'error');
40
+ assert.equal(result.message, 'Invalid request');
41
+ });
42
+
43
+ test('metrics displays a form requesting an auth code on a GET request', async () => {
44
+ const result: any = await answer(null, '', 'GET');
45
+ assert.equal(result.status, 'ok');
46
+ const html = parse(result.answerPage);
47
+ const input = html.querySelector('input[name="authCode"]');
48
+ assert.ok(input);
49
+ assert.equal(html.querySelectorAll('table').length, 0);
50
+ });
51
+
52
+ test('metrics displays a clearCounts checkbox in the initial form', async () => {
53
+ const result: any = await answer(null, '', 'GET');
54
+ const html = parse(result.answerPage);
55
+ const checkbox = html.querySelector('input[name="clearCounts"]');
56
+ assert.ok(checkbox);
57
+ assert.equal(checkbox.getAttribute('type'), 'checkbox');
58
+ });
59
+
60
+ test('metrics submits its form as a POST', async () => {
61
+ const result: any = await answer(null, '', 'GET');
62
+ const html = parse(result.answerPage);
63
+ const form = html.querySelector('form');
64
+ assert.equal(form?.getAttribute('method'), 'post');
65
+ });
66
+
67
+ test('metrics ignores a valid authCode query string on a GET request', async () => {
68
+ const result: any = await answer(null, 'authCode=test-auth-code', 'GET');
69
+ assert.equal(result.status, 'ok');
70
+ const html = parse(result.answerPage);
71
+ // The form (not the counts) should still be shown, since only POST submits.
72
+ assert.ok(html.querySelector('input[name="authCode"]'));
73
+ assert.equal(html.querySelectorAll('table').length, 0);
74
+ });
75
+
76
+ test('metrics ignores clearCounts on a GET request, even with a valid authCode', async () => {
77
+ await fs.writeFile(metricsPath(), getJSON({
78
+ since: '260101T0000',
79
+ pageViews: {tutorialWeb: 9},
80
+ mcpToolCalls: {},
81
+ apiOperations: {},
82
+ managerActivity: {}
83
+ }));
84
+ const result: any = await answer(null, 'authCode=test-auth-code&clearCounts=on', 'GET');
85
+ assert.ok(!result.answerPage.includes('Counts cleared.'));
86
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
87
+ // The counts should NOT have been cleared by a mere GET request.
88
+ assert.equal(metrics.pageViews.tutorialWeb, 9);
89
+ await fs.rm(metricsPath(), {force: true});
90
+ });
91
+
92
+ test('metrics reports no counts yet when the metrics file does not exist', async () => {
93
+ await fs.rm(metricsPath(), {force: true});
94
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
95
+ assert.equal(result.status, 'ok');
96
+ assert.ok(result.answerPage.includes('Counts recorded since'));
97
+ const html = parse(result.answerPage);
98
+ assert.equal(html.querySelectorAll('table').length, 0);
99
+ await fs.rm(metricsPath(), {force: true});
100
+ });
101
+
102
+ test('metrics reports recorded counts with a valid auth code on a POST request', async () => {
103
+ await fs.writeFile(metricsPath(), getJSON({
104
+ since: '260101T0000',
105
+ pageViews: {tutorialWeb: 3},
106
+ mcpToolCalls: {listReports: 5},
107
+ apiOperations: {},
108
+ managerActivity: {}
109
+ }));
110
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
111
+ assert.equal(result.status, 'ok');
112
+ const html = parse(result.answerPage);
113
+ const tables = html.querySelectorAll('table');
114
+ assert.equal(tables.length, 2);
115
+ assert.ok(result.answerPage.includes('tutorialWeb'));
116
+ assert.ok(result.answerPage.includes('5'));
117
+ assert.ok(result.answerPage.includes('at 00:00'));
118
+ await fs.rm(metricsPath(), {force: true});
119
+ });
120
+
121
+ test('metrics sorts each category by descending count', async () => {
122
+ await fs.writeFile(metricsPath(), getJSON({
123
+ since: '260101T0000',
124
+ pageViews: {manage: 1, tutorialWeb: 9, otherPage: 4},
125
+ mcpToolCalls: {},
126
+ apiOperations: {},
127
+ managerActivity: {}
128
+ }));
129
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
130
+ const html = parse(result.answerPage);
131
+ const names = html.querySelectorAll('table')[0]!.querySelectorAll('tbody td:first-child')
132
+ .map(cell => cell.text);
133
+ assert.deepEqual(names, ['tutorialWeb', 'otherPage', 'manage']);
134
+ await fs.rm(metricsPath(), {force: true});
135
+ });
136
+
137
+ test('metrics reports manager page activity, sorted by descending failure count', async () => {
138
+ await fs.writeFile(metricsPath(), getJSON({
139
+ since: '260101T0000',
140
+ pageViews: {},
141
+ mcpToolCalls: {},
142
+ apiOperations: {},
143
+ managerActivity: {
144
+ metrics: {ok: 5, error: 0},
145
+ reannotate: {ok: 1, error: 8}
146
+ }
147
+ }));
148
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
149
+ assert.ok(result.answerPage.includes('Manager page activity'));
150
+ const html = parse(result.answerPage);
151
+ const tables = html.querySelectorAll('table');
152
+ assert.equal(tables.length, 1);
153
+ const managerTable = tables[0]!;
154
+ const rows = managerTable.querySelectorAll('tbody tr').map(row =>
155
+ row.querySelectorAll('td').map(cell => cell.text)
156
+ );
157
+ assert.deepEqual(rows, [['reannotate', '1', '8'], ['metrics', '5', '0']]);
158
+ await fs.rm(metricsPath(), {force: true});
159
+ });
160
+
161
+ test('metrics reports no manager activity yet when none has been recorded', async () => {
162
+ await fs.writeFile(metricsPath(), getJSON({
163
+ since: '260101T0000',
164
+ pageViews: {},
165
+ mcpToolCalls: {},
166
+ apiOperations: {},
167
+ managerActivity: {}
168
+ }));
169
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
170
+ const html = parse(result.answerPage);
171
+ assert.equal(html.querySelectorAll('table').length, 0);
172
+ assert.ok(result.answerPage.includes('None yet.'));
173
+ await fs.rm(metricsPath(), {force: true});
174
+ });
175
+
176
+ test('metrics clears all counts and shows a confirmation when clearCounts is submitted via POST', async () => {
177
+ await fs.writeFile(metricsPath(), getJSON({
178
+ since: '260101T0000',
179
+ pageViews: {tutorialWeb: 9},
180
+ mcpToolCalls: {listReports: 3},
181
+ apiOperations: {getReport: 1},
182
+ managerActivity: {reannotate: {ok: 1, error: 2}}
183
+ }));
184
+ const result: any = await answer(null, 'authCode=test-auth-code&clearCounts=on', 'POST');
185
+ assert.equal(result.status, 'ok');
186
+ assert.ok(result.answerPage.includes('Counts cleared.'));
187
+ const html = parse(result.answerPage);
188
+ assert.equal(html.querySelectorAll('table').length, 0);
189
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
190
+ assert.deepEqual(metrics.pageViews, {});
191
+ assert.deepEqual(metrics.mcpToolCalls, {});
192
+ assert.deepEqual(metrics.apiOperations, {});
193
+ assert.deepEqual(metrics.managerActivity, {});
194
+ assert.notEqual(metrics.since, '260101T0000');
195
+ await fs.rm(metricsPath(), {force: true});
196
+ });
197
+
198
+ test('metrics does not clear counts, and does not show a confirmation, when clearCounts is not submitted', async () => {
199
+ await fs.writeFile(metricsPath(), getJSON({
200
+ since: '260101T0000',
201
+ pageViews: {tutorialWeb: 9},
202
+ mcpToolCalls: {},
203
+ apiOperations: {},
204
+ managerActivity: {}
205
+ }));
206
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
207
+ assert.ok(!result.answerPage.includes('Counts cleared.'));
208
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
209
+ assert.equal(metrics.pageViews.tutorialWeb, 9);
210
+ await fs.rm(metricsPath(), {force: true});
211
+ });
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Prune reports</h1>
16
16
  <p>__intro__</p>
17
- <form action="/pruneReportsForm.html">
17
+ <form action="/pruneReportsForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -10,7 +10,7 @@ import {reportDeletionForm} from '../reportDeletion.ts';
10
10
  // FUNCTIONS
11
11
 
12
12
  // Returns a form for deleting non-latest reports.
13
- export const answer = async (_: any, search: string) => reportDeletionForm(import.meta.dirname, search, {
13
+ export const answer = async (_: any, search: string, method: string) => reportDeletionForm(import.meta.dirname, search, method, {
14
14
  intro: 'Choose the superseded reports to delete.',
15
15
  emptyIntro: 'Each target has only 1 report, so there are no superseded reports to delete.',
16
16
  failurePrefix: 'Deleting superseded reports failed',
@@ -35,32 +35,40 @@ after(async () => {
35
35
 
36
36
  // TESTS
37
37
 
38
- test('pruneReportsForm displays a list of reports when no submission', async () => {
39
- const result = await answer(null, '');
38
+ test('pruneReportsForm 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('pruneReportsForm returns an error for an invalid auth code', async () => {
47
- const result = await answer(null, 'authCode=wrong&report=260101T0000-mix');
46
+ test('pruneReportsForm ignores a report query string on a GET request', async () => {
47
+ const reportPath = path.join(reportsPath(), '260101T0000-mix.json');
48
+ const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix', '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('pruneReportsForm returns an error for an invalid auth code on a POST request', async () => {
55
+ const result = await answer(null, 'authCode=wrong&report=260101T0000-mix', '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('pruneReportsForm returns an error when deleting a nonexistent report', async () => {
53
- const result = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
60
+ test('pruneReportsForm 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 superseded reports'));
56
64
  });
57
65
 
58
- test('pruneReportsForm deletes a superseded report with valid auth code', async () => {
66
+ test('pruneReportsForm deletes a superseded report with valid auth code on a POST request', async () => {
59
67
  // 260101T0000-mix is superseded by 260202T0000-new (same URL).
60
68
  const reportPath = path.join(reportsPath(), '260101T0000-mix.json');
61
69
  const backup = await fs.readFile(reportPath, 'utf8');
62
70
  try {
63
- const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix');
71
+ const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix', '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('pruneReportsForm returns an error when a report file is corrupt', async ()
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 {
@@ -63,9 +63,12 @@ export const answer = async (authCode: string) => {
63
63
  answerPage
64
64
  };
65
65
  }
66
- // Otherwise, i.e. if the authorization code is invalid, return an error page.
66
+ // Otherwise, i.e. if the authorization code is invalid, return an error page. The
67
+ // message is deliberately vague so as not to confirm to an attacker that the
68
+ // authorization code specifically (as opposed to some other part of the request) is
69
+ // what was wrong.
67
70
  return {
68
71
  status: 'error',
69
- message: 'Invalid authorization code'
72
+ message: 'Invalid request'
70
73
  };
71
74
  };
@@ -59,7 +59,7 @@ test('answer returns an error for an invalid authorization code', async () => {
59
59
  const {answer} = await import('./index.ts');
60
60
  const result = await answer('wrong-code');
61
61
  assert.equal(result.status, 'error');
62
- assert.equal(result.message, 'Invalid authorization code');
62
+ assert.equal(result.message, 'Invalid request');
63
63
  });
64
64
 
65
65
  test('answer returns an error when no reports are available', async () => {
@@ -61,10 +61,13 @@ export const answer = async (authCode: string) => {
61
61
  };
62
62
  }
63
63
  }
64
+ // The message is deliberately vague so as not to confirm to an attacker that the
65
+ // authorization code specifically (as opposed to some other part of the request) is
66
+ // what was wrong.
64
67
  else {
65
68
  return {
66
69
  status: 'error',
67
- message: 'Authorization code invalid'
70
+ message: 'Invalid request'
68
71
  };
69
72
  }
70
73
  };
@@ -65,7 +65,7 @@ test('answer returns an error for an invalid authorization code', async () => {
65
65
  const {answer} = await import('./index.ts');
66
66
  const result = await answer('wrong-code');
67
67
  assert.equal(result.status, 'error');
68
- assert.equal(result.message, 'Authorization code invalid');
68
+ assert.equal(result.message, 'Invalid request');
69
69
  });
70
70
 
71
71
  test('answer returns an error when the WCAG map source returns a non-200 status', async () => {
@@ -32,18 +32,21 @@ export type DeletionSpec = {
32
32
 
33
33
  // FUNCTIONS
34
34
 
35
- // Returns a form for deleting the reports that the predicate marks as deletable.
35
+ // Returns a form for deleting the reports that the predicate marks as deletable. A GET
36
+ // request never processes a submission, regardless of its query string; only a POST
37
+ // request (the form's own submission) does.
36
38
  export const reportDeletionForm = async (
37
39
  dirName: string,
38
40
  search: string,
41
+ method: string,
39
42
  deletionSpec: DeletionSpec
40
43
  ): Promise<{status: string; message?: string; answerPage?: string}> => {
41
44
  const {emptyIntro, failurePrefix, intro, isDeletable} = deletionSpec;
42
45
  const searchParams = new URLSearchParams(search);
43
46
  const authCode = searchParams?.get('authCode');
44
47
  const jobNames = searchParams?.getAll('report');
45
- // If the form has been displayed by itself after a submission and any reports are to be deleted:
46
- if (jobNames?.length) {
48
+ // If the form has been submitted and any reports are to be deleted:
49
+ if (method === 'POST' && jobNames?.length) {
47
50
  // If the authorization code is valid:
48
51
  if (authCode === process.env.AUTH_CODE) {
49
52
  try {
@@ -64,10 +67,12 @@ export const reportDeletionForm = async (
64
67
  }
65
68
  // Otherwise, i.e. if the authorization code is invalid:
66
69
  else {
67
- // Report the error.
70
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
71
+ // authorization code specifically (as opposed to some other part of the request) is
72
+ // what was wrong.
68
73
  return {
69
74
  status: 'error',
70
- message: 'Invalid authorization code'
75
+ message: 'Invalid request'
71
76
  };
72
77
  }
73
78
  }
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Rewind reports</h1>
16
16
  <p>__intro__</p>
17
- <form action="/rewindReportsForm.html">
17
+ <form action="/rewindReportsForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -10,7 +10,7 @@ import {reportDeletionForm} from '../reportDeletion.ts';
10
10
  // FUNCTIONS
11
11
 
12
12
  // Returns a form for deleting latest superseding reports.
13
- export const answer = async (_: any, search: string) => reportDeletionForm(import.meta.dirname, search, {
13
+ export const answer = async (_: any, search: string, method: string) => reportDeletionForm(import.meta.dirname, search, method, {
14
14
  intro: 'Choose the latest superseding reports to delete.',
15
15
  emptyIntro: 'Each target has only 1 report, so there are no reports to delete.',
16
16
  failurePrefix: 'Deleting latest superseding reports failed',