@jrpool/kilotest 48.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/.claude/scheduled_tasks.lock +1 -0
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +23 -3
- package/api/requestFeature.ts +19 -11
- 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 +232 -12
- package/docs/SERVICE.md +84 -3
- package/index.test.ts +443 -20
- package/index.ts +246 -27
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +31 -1
- package/mcp.ts +9 -0
- package/package.json +1 -1
- package/smokeTest.ts +16 -0
- package/test/dbFixture.ts +8 -0
- package/util.test.ts +295 -0
- package/util.ts +121 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +12 -8
- package/web/enqueue/index.ts +33 -39
- package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
- package/web/expungeReportsForm/index.html +1 -1
- package/web/expungeReportsForm/index.ts +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +24 -9
- package/web/hideReportForm/index.html +1 -1
- package/web/hideReportForm/index.ts +13 -8
- package/web/listTopIssues/index.ts +1 -1
- package/web/manage/index.html +2 -1
- package/web/metrics/index.html +19 -0
- package/web/metrics/index.ts +127 -0
- package/web/metrics/metrics.test.ts +211 -0
- package/web/pruneReportsForm/index.html +1 -1
- package/web/pruneReportsForm/index.ts +1 -1
- package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
- package/web/reannotate/index.ts +7 -3
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +6 -3
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +13 -7
- package/web/rewindReportsForm/index.html +1 -1
- package/web/rewindReportsForm/index.ts +1 -1
- package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
- package/web/showHiddenReportsForm/index.html +24 -0
- package/web/showHiddenReportsForm/index.ts +45 -0
- package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
- package/web/tutorialAI/index.ts +1 -1
- package/web/tutorialWeb/index.ts +1 -1
- package/web/unhideReportForm/index.html +1 -1
- package/web/unhideReportForm/index.ts +13 -8
- package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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
|
|
47
|
-
const
|
|
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
|
|
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 {
|
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'));
|
|
@@ -63,9 +64,12 @@ export const answer = async (authCode: string) => {
|
|
|
63
64
|
answerPage
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
|
-
// Otherwise, i.e. if the authorization code is invalid, return an error page.
|
|
67
|
+
// Otherwise, i.e. if the authorization code is invalid, return an error page. The
|
|
68
|
+
// message is 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.
|
|
67
71
|
return {
|
|
68
72
|
status: 'error',
|
|
69
|
-
message: 'Invalid
|
|
73
|
+
message: 'Invalid request'
|
|
70
74
|
};
|
|
71
75
|
};
|
|
@@ -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
|
|
62
|
+
assert.equal(result.message, 'Invalid request');
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test('answer returns an error when no reports are available', async () => {
|
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;
|
|
@@ -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: '
|
|
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, '
|
|
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 () => {
|
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';
|
|
@@ -32,20 +33,23 @@ export type DeletionSpec = {
|
|
|
32
33
|
|
|
33
34
|
// FUNCTIONS
|
|
34
35
|
|
|
35
|
-
// Returns a form for deleting the reports that the predicate marks as deletable.
|
|
36
|
+
// Returns a form for deleting the reports that the predicate marks as deletable. A GET
|
|
37
|
+
// request never processes a submission, regardless of its query string; only a POST
|
|
38
|
+
// request (the form's own submission) does.
|
|
36
39
|
export const reportDeletionForm = async (
|
|
37
40
|
dirName: string,
|
|
38
41
|
search: string,
|
|
42
|
+
method: string,
|
|
39
43
|
deletionSpec: DeletionSpec
|
|
40
44
|
): Promise<{status: string; message?: string; answerPage?: string}> => {
|
|
41
45
|
const {emptyIntro, failurePrefix, intro, isDeletable} = deletionSpec;
|
|
42
46
|
const searchParams = new URLSearchParams(search);
|
|
43
47
|
const authCode = searchParams?.get('authCode');
|
|
44
48
|
const jobNames = searchParams?.getAll('report');
|
|
45
|
-
// If the form has been
|
|
46
|
-
if (jobNames?.length) {
|
|
49
|
+
// If the form has been submitted and any reports are to be deleted:
|
|
50
|
+
if (method === 'POST' && jobNames?.length) {
|
|
47
51
|
// If the authorization code is valid:
|
|
48
|
-
if (authCode
|
|
52
|
+
if (isValidAuthCode(authCode)) {
|
|
49
53
|
try {
|
|
50
54
|
// For each report to be deleted:
|
|
51
55
|
for (const jobName of jobNames) {
|
|
@@ -64,10 +68,12 @@ export const reportDeletionForm = async (
|
|
|
64
68
|
}
|
|
65
69
|
// Otherwise, i.e. if the authorization code is invalid:
|
|
66
70
|
else {
|
|
67
|
-
// Report the error
|
|
71
|
+
// Report the error, deliberately vague so as not to confirm to an attacker that the
|
|
72
|
+
// authorization code specifically (as opposed to some other part of the request) is
|
|
73
|
+
// what was wrong.
|
|
68
74
|
return {
|
|
69
75
|
status: 'error',
|
|
70
|
-
message: 'Invalid
|
|
76
|
+
message: 'Invalid request'
|
|
71
77
|
};
|
|
72
78
|
}
|
|
73
79
|
}
|
|
@@ -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',
|
|
@@ -35,32 +35,40 @@ after(async () => {
|
|
|
35
35
|
|
|
36
36
|
// TESTS
|
|
37
37
|
|
|
38
|
-
test('rewindReportsForm displays a list of reports
|
|
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
|
|
47
|
-
const
|
|
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
|
|
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 {isValidAuthCode, 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 (!isValidAuthCode(authCode)) {
|
|
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
|
+
};
|