@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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/api/requestFeature.test.ts +4 -2
- package/api/requestFeature.ts +1 -1
- package/docs/BACKLOG.md +220 -12
- package/docs/SERVICE.md +52 -1
- package/index.test.ts +334 -13
- package/index.ts +174 -10
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +30 -0
- 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 +231 -0
- package/util.ts +97 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +10 -6
- package/web/enqueue/index.ts +5 -2
- 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 +9 -6
- 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 +126 -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 +5 -2
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +4 -1
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +10 -5
- 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 +9 -6
- 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
|
-
|
|
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
|
|
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
|
|
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:
|
package/web/manage/index.html
CHANGED
|
@@ -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="/
|
|
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
|
+
});
|
|
@@ -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
|
@@ -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
|
|
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
|
|
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
|
@@ -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
|
@@ -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
|
|
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
|
|
75
|
+
message: 'Invalid request'
|
|
71
76
|
};
|
|
72
77
|
}
|
|
73
78
|
}
|
|
@@ -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',
|