@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
package/index.test.ts
CHANGED
|
@@ -32,6 +32,7 @@ const {requestHandler, routes, serveError, startServer, runIfMain, isPathAllowed
|
|
|
32
32
|
|
|
33
33
|
const port = 3997;
|
|
34
34
|
const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
|
|
35
|
+
const metricsPath = path.join(fixtureDBDir, 'metrics.json');
|
|
35
36
|
|
|
36
37
|
// SETUP AND TEARDOWN
|
|
37
38
|
|
|
@@ -146,6 +147,31 @@ const jsonBody = (res: any) => {
|
|
|
146
147
|
return null;
|
|
147
148
|
}
|
|
148
149
|
};
|
|
150
|
+
// Returns the current count for a metrics category and name, or 0 if the file or the
|
|
151
|
+
// entry is absent (the file does not exist until the first metric is recorded).
|
|
152
|
+
const getMetricCount = async (category: string, name: string): Promise<number> => {
|
|
153
|
+
let metricsJSON: string;
|
|
154
|
+
try {
|
|
155
|
+
metricsJSON = await fs.readFile(metricsPath, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const metrics = JSON.parse(metricsJSON);
|
|
161
|
+
return metrics[category][name] ?? 0;
|
|
162
|
+
};
|
|
163
|
+
// Returns the current managerActivity ok/error count for a page name, or 0 if absent.
|
|
164
|
+
const getManagerActivityCount = async (name: string, outcome: 'ok' | 'error'): Promise<number> => {
|
|
165
|
+
let metricsJSON: string;
|
|
166
|
+
try {
|
|
167
|
+
metricsJSON = await fs.readFile(metricsPath, 'utf8');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const metrics = JSON.parse(metricsJSON);
|
|
173
|
+
return metrics.managerActivity[name]?.[outcome] ?? 0;
|
|
174
|
+
};
|
|
149
175
|
|
|
150
176
|
// TESTS: routes table
|
|
151
177
|
|
|
@@ -267,11 +293,13 @@ test('GET /favicon.ico serves the favicon as an icon', async () => {
|
|
|
267
293
|
assert.ok(res.headers['content-type'].includes('image/x-icon'));
|
|
268
294
|
});
|
|
269
295
|
|
|
270
|
-
test('GET /api/listReports returns JSON with report data', async () => {
|
|
296
|
+
test('GET /api/listReports returns JSON with report data and records an API-operation metric', async () => {
|
|
297
|
+
const countBefore = await getMetricCount('apiOperations', 'listReports');
|
|
271
298
|
const res = await request('GET', '/api/listReports');
|
|
272
299
|
assert.equal(res.statusCode, 200);
|
|
273
300
|
const body = jsonBody(res);
|
|
274
301
|
assert.equal(body['tool name'], 'listReports');
|
|
302
|
+
assert.equal(await getMetricCount('apiOperations', 'listReports'), countBefore + 1);
|
|
275
303
|
});
|
|
276
304
|
|
|
277
305
|
test('GET /api/listIssues/260101T0000/mix returns JSON with issue data', async () => {
|
|
@@ -466,13 +494,15 @@ test('POST /api/requestRetest/260202T0000/new with valid JSON returns a JSON res
|
|
|
466
494
|
assert.ok(body['tool name'] || body['response content']);
|
|
467
495
|
});
|
|
468
496
|
|
|
469
|
-
test('POST /api/requestFeature with valid JSON returns a JSON response', async () => {
|
|
497
|
+
test('POST /api/requestFeature with valid JSON returns a JSON response and records an API-operation metric', async () => {
|
|
498
|
+
const countBefore = await getMetricCount('apiOperations', 'requestFeature');
|
|
470
499
|
const res = await request('POST', '/api/requestFeature', {
|
|
471
500
|
feature: 'Add a dark mode toggle'
|
|
472
501
|
});
|
|
473
502
|
assert.equal(res.statusCode, 200);
|
|
474
503
|
const body = jsonBody(res);
|
|
475
504
|
assert.ok(body['tool name'] || body['response content']);
|
|
505
|
+
assert.equal(await getMetricCount('apiOperations', 'requestFeature'), countBefore + 1);
|
|
476
506
|
});
|
|
477
507
|
|
|
478
508
|
test('POST /api/invalidService returns an error', async () => {
|
|
@@ -616,35 +646,41 @@ test('serveError sends HTML for human requests (isHumanUser = true)', async () =
|
|
|
616
646
|
|
|
617
647
|
// TESTS: requestAction.html
|
|
618
648
|
|
|
619
|
-
test('POST /requestAction.html with invalid auth code returns an error', {timeout: 500}, async () => {
|
|
649
|
+
test('POST /requestAction.html with invalid auth code returns an error and records a managerActivity failure', {timeout: 500}, async () => {
|
|
650
|
+
const countBefore = await getManagerActivityCount('requestAction.html', 'error');
|
|
620
651
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
621
652
|
target: 'https://example.com\tTest Page',
|
|
622
653
|
authCode: 'wrong-code'
|
|
623
654
|
});
|
|
624
655
|
assert.equal(res.statusCode, 400);
|
|
625
|
-
assert.ok(res.body.includes('Invalid
|
|
656
|
+
assert.ok(res.body.includes('Invalid request'));
|
|
657
|
+
assert.equal(await getManagerActivityCount('requestAction.html', 'error'), countBefore + 1);
|
|
626
658
|
});
|
|
627
659
|
|
|
628
|
-
test('POST /requestAction.html with valid auth code and rejection (no description) returns HTML', async () => {
|
|
660
|
+
test('POST /requestAction.html with valid auth code and rejection (no description) returns HTML and records a managerActivity success', async () => {
|
|
629
661
|
await formRequest('POST', '/requestTest.html', {
|
|
630
662
|
description: `Reject Test Page ${uniqueStamp}`,
|
|
631
663
|
url: `https://example.com/reject-${uniqueStamp}`,
|
|
632
664
|
why: 'Because accessibility matters'
|
|
633
665
|
});
|
|
666
|
+
const countBefore = await getManagerActivityCount('requestAction.html', 'ok');
|
|
634
667
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
635
668
|
target: `https://example.com/reject-${uniqueStamp}`,
|
|
636
669
|
authCode: 'test-auth-code'
|
|
637
670
|
});
|
|
638
671
|
assert.equal(res.statusCode, 200);
|
|
639
672
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
673
|
+
assert.equal(await getManagerActivityCount('requestAction.html', 'ok'), countBefore + 1);
|
|
640
674
|
});
|
|
641
675
|
|
|
642
676
|
// TESTS: tutorialWeb (web user tutorial)
|
|
643
677
|
|
|
644
|
-
test('GET /tutorialWeb.html returns HTML', async () => {
|
|
678
|
+
test('GET /tutorialWeb.html returns HTML and records a page-view metric', async () => {
|
|
679
|
+
const countBefore = await getMetricCount('pageViews', 'tutorialWeb');
|
|
645
680
|
const res = await request('GET', '/tutorialWeb.html');
|
|
646
681
|
assert.equal(res.statusCode, 200);
|
|
647
682
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
683
|
+
assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), countBefore + 1);
|
|
648
684
|
});
|
|
649
685
|
|
|
650
686
|
test('POST /tutorialWebComment.html with content returns JSON', async () => {
|
|
@@ -678,14 +714,16 @@ test('POST /requestAction.html with valid auth code and approval returns HTML',
|
|
|
678
714
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
679
715
|
});
|
|
680
716
|
|
|
681
|
-
test('POST /reannotate.html with invalid auth code returns an error page', async () => {
|
|
717
|
+
test('POST /reannotate.html with invalid auth code returns an error page and records a managerActivity failure', async () => {
|
|
718
|
+
const countBefore = await getManagerActivityCount('reannotate.html', 'error');
|
|
682
719
|
const res = await formRequest('POST', '/reannotate.html', {
|
|
683
720
|
authCode: 'wrong-code'
|
|
684
721
|
});
|
|
685
722
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
723
|
+
assert.equal(await getManagerActivityCount('reannotate.html', 'error'), countBefore + 1);
|
|
686
724
|
});
|
|
687
725
|
|
|
688
|
-
test('POST /reannotate.html with valid auth code serves the answer page', async () => {
|
|
726
|
+
test('POST /reannotate.html with valid auth code serves the answer page and records a managerActivity success', async () => {
|
|
689
727
|
// Back up all fixture reports, because reannotation modifies them in place.
|
|
690
728
|
const reportsDir = path.join(fixtureDBDir, 'reports');
|
|
691
729
|
const reportFiles = await fs.readdir(reportsDir);
|
|
@@ -694,12 +732,14 @@ test('POST /reannotate.html with valid auth code serves the answer page', async
|
|
|
694
732
|
backups[file] = await fs.readFile(path.join(reportsDir, file), 'utf8');
|
|
695
733
|
}
|
|
696
734
|
try {
|
|
735
|
+
const countBefore = await getManagerActivityCount('reannotate.html', 'ok');
|
|
697
736
|
const res = await formRequest('POST', '/reannotate.html', {
|
|
698
737
|
authCode: 'test-auth-code'
|
|
699
738
|
});
|
|
700
739
|
assert.equal(res.statusCode, 200);
|
|
701
740
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
702
741
|
assert.ok(res.body.includes('Reannotation order'));
|
|
742
|
+
assert.equal(await getManagerActivityCount('reannotate.html', 'ok'), countBefore + 1);
|
|
703
743
|
}
|
|
704
744
|
finally {
|
|
705
745
|
// Restore all fixture reports.
|
|
@@ -709,14 +749,16 @@ test('POST /reannotate.html with valid auth code serves the answer page', async
|
|
|
709
749
|
}
|
|
710
750
|
});
|
|
711
751
|
|
|
712
|
-
test('POST /renewWCAG.html with invalid auth code returns an error page', async () => {
|
|
752
|
+
test('POST /renewWCAG.html with invalid auth code returns an error page and records a managerActivity failure', async () => {
|
|
753
|
+
const countBefore = await getManagerActivityCount('renewWCAG.html', 'error');
|
|
713
754
|
const res = await formRequest('POST', '/renewWCAG.html', {
|
|
714
755
|
authCode: 'wrong-code'
|
|
715
756
|
});
|
|
716
757
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
758
|
+
assert.equal(await getManagerActivityCount('renewWCAG.html', 'error'), countBefore + 1);
|
|
717
759
|
});
|
|
718
760
|
|
|
719
|
-
test('POST /renewWCAG.html with valid auth code serves the answer page', async () => {
|
|
761
|
+
test('POST /renewWCAG.html with valid auth code serves the answer page and records a managerActivity success', async () => {
|
|
720
762
|
// Mock fetch to avoid a network dependency.
|
|
721
763
|
const originalFetch = global.fetch;
|
|
722
764
|
const wcagMapPath = path.join(import.meta.dirname, 'wcagMap.json');
|
|
@@ -727,12 +769,14 @@ test('POST /renewWCAG.html with valid auth code serves the answer page', async (
|
|
|
727
769
|
text: async () => '<a href="understanding/contrast-minimum"><span class="secno">1.4.3 </span>'
|
|
728
770
|
});
|
|
729
771
|
try {
|
|
772
|
+
const countBefore = await getManagerActivityCount('renewWCAG.html', 'ok');
|
|
730
773
|
const res = await formRequest('POST', '/renewWCAG.html', {
|
|
731
774
|
authCode: 'test-auth-code'
|
|
732
775
|
});
|
|
733
776
|
assert.equal(res.statusCode, 200);
|
|
734
777
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
735
778
|
assert.ok(res.body.includes('WCAG map renewed'));
|
|
779
|
+
assert.equal(await getManagerActivityCount('renewWCAG.html', 'ok'), countBefore + 1);
|
|
736
780
|
}
|
|
737
781
|
finally {
|
|
738
782
|
global.fetch = originalFetch;
|
|
@@ -782,6 +826,40 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
|
|
|
782
826
|
assert.equal(res.headers['location'], '/tutorialAI.html');
|
|
783
827
|
});
|
|
784
828
|
|
|
829
|
+
test('GET /diagnoses.html/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
|
|
830
|
+
const res = await request(
|
|
831
|
+
'GET', '/diagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body', {}, {followRedirects: false}
|
|
832
|
+
);
|
|
833
|
+
assert.equal(res.statusCode, 301);
|
|
834
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/duplicateID/260426T1741/77w/718?pathID=/html/body');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test('GET /reportIssues.html/<timeStamp>/<jobID> redirects to the equivalent /listIssues.html/<timeStamp>/<jobID>', async () => {
|
|
838
|
+
const res = await request('GET', '/reportIssues.html/260101T0000/mix', {}, {followRedirects: false});
|
|
839
|
+
assert.equal(res.statusCode, 301);
|
|
840
|
+
assert.equal(res.headers['location'], '/listIssues.html/260101T0000/mix');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('GET /reportIssue.html/<segments> redirects to the equivalent /listViolators.html/<segments>', async () => {
|
|
844
|
+
const res = await request(
|
|
845
|
+
'GET', '/reportIssue.html/duplicateID/260101T0000/mix', {}, {followRedirects: false}
|
|
846
|
+
);
|
|
847
|
+
assert.equal(res.statusCode, 301);
|
|
848
|
+
assert.equal(res.headers['location'], '/listViolators.html/duplicateID/260101T0000/mix');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('GET /rules.html/<segment> redirects to the equivalent /listRules.html/<segment>', async () => {
|
|
852
|
+
const res = await request('GET', '/rules.html/allCaps', {}, {followRedirects: false});
|
|
853
|
+
assert.equal(res.statusCode, 301);
|
|
854
|
+
assert.equal(res.headers['location'], '/listRules.html/allCaps');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test('GET /targets.html redirects to /listReports.html', async () => {
|
|
858
|
+
const res = await request('GET', '/targets.html', {}, {followRedirects: false});
|
|
859
|
+
assert.equal(res.statusCode, 301);
|
|
860
|
+
assert.equal(res.headers['location'], '/listReports.html');
|
|
861
|
+
});
|
|
862
|
+
|
|
785
863
|
test('POST /requestTest.html with non-https URL returns an error', async () => {
|
|
786
864
|
const res = await formRequest('POST', '/requestTest.html', {
|
|
787
865
|
description: 'Test Page',
|
|
@@ -902,6 +980,101 @@ test('POST /worker/job with a claimed job assigned to the worker returns an erro
|
|
|
902
980
|
await fs.unlink(failedJobPath).catch(() => {});
|
|
903
981
|
});
|
|
904
982
|
|
|
983
|
+
test('POST /worker/job skips a claimed job file that disappears between the directory listing and the read', {timeout: 500}, async (t) => {
|
|
984
|
+
// Create a claimed job assigned to a different worker, so it does not match this
|
|
985
|
+
// worker and would otherwise just be skipped normally, then make the read of that
|
|
986
|
+
// specific file (not the directory listing) throw ENOENT, simulating the file
|
|
987
|
+
// vanishing between getJobNames listing the claimed directory and this loop's read
|
|
988
|
+
// of the file it found there (e.g. because a concurrent report submission completed
|
|
989
|
+
// the job just after the listing).
|
|
990
|
+
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
991
|
+
const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
992
|
+
for (const dir of [claimedDir, queueDir]) {
|
|
993
|
+
await fs.mkdir(dir, {recursive: true});
|
|
994
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
995
|
+
for (const file of files) {
|
|
996
|
+
await fs.unlink(path.join(dir, file)).catch(() => {});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const jobFile = '260101T0000-mix.json';
|
|
1000
|
+
const claimedJobPath = path.join(claimedDir, jobFile);
|
|
1001
|
+
await fs.writeFile(claimedJobPath, JSON.stringify({
|
|
1002
|
+
id: '260101T0000-mix',
|
|
1003
|
+
target: {what: 'Test', url: 'https://example.com/test'},
|
|
1004
|
+
sources: {worker: 'Worker Two'}
|
|
1005
|
+
}));
|
|
1006
|
+
const originalReadFile = fs.readFile;
|
|
1007
|
+
t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
|
|
1008
|
+
if (String(filePath) === claimedJobPath) {
|
|
1009
|
+
await fs.unlink(claimedJobPath);
|
|
1010
|
+
const error: any = new Error('ENOENT: no such file or directory');
|
|
1011
|
+
error.code = 'ENOENT';
|
|
1012
|
+
throw error;
|
|
1013
|
+
}
|
|
1014
|
+
return (originalReadFile as any)(filePath, ...rest);
|
|
1015
|
+
});
|
|
1016
|
+
try {
|
|
1017
|
+
const auth = Buffer.from('worker1:secret1').toString('base64');
|
|
1018
|
+
const res = await request('POST', '/worker/job', {}, {
|
|
1019
|
+
authorization: `Basic ${auth}`
|
|
1020
|
+
});
|
|
1021
|
+
// With no queued jobs and the claimed job having vanished mid-read, the worker
|
|
1022
|
+
// should just get told there is no job, rather than a 500 error from an
|
|
1023
|
+
// unhandled ENOENT.
|
|
1024
|
+
assert.equal(res.statusCode, 200);
|
|
1025
|
+
const body = jsonBody(res);
|
|
1026
|
+
assert.ok(!body.error);
|
|
1027
|
+
}
|
|
1028
|
+
finally {
|
|
1029
|
+
t.mock.reset();
|
|
1030
|
+
await fs.unlink(claimedJobPath).catch(() => {});
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test('POST /worker/job propagates a non-ENOENT failure to read a claimed job file', {timeout: 500}, async (t) => {
|
|
1035
|
+
// The catch in processJobRequest's claimed-job loop rethrows any failure other than
|
|
1036
|
+
// the file having vanished (ENOENT). This confirms that a different failure, such as
|
|
1037
|
+
// a permissions error, is not swallowed alongside the expected race condition.
|
|
1038
|
+
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
1039
|
+
const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
1040
|
+
for (const dir of [claimedDir, queueDir]) {
|
|
1041
|
+
await fs.mkdir(dir, {recursive: true});
|
|
1042
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
1043
|
+
for (const file of files) {
|
|
1044
|
+
await fs.unlink(path.join(dir, file)).catch(() => {});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const jobFile = '260101T0000-mix.json';
|
|
1048
|
+
const claimedJobPath = path.join(claimedDir, jobFile);
|
|
1049
|
+
await fs.writeFile(claimedJobPath, JSON.stringify({
|
|
1050
|
+
id: '260101T0000-mix',
|
|
1051
|
+
target: {what: 'Test', url: 'https://example.com/test'},
|
|
1052
|
+
sources: {worker: 'Worker Two'}
|
|
1053
|
+
}));
|
|
1054
|
+
const originalReadFile = fs.readFile;
|
|
1055
|
+
t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
|
|
1056
|
+
if (String(filePath) === claimedJobPath) {
|
|
1057
|
+
const error: any = new Error('EACCES: permission denied');
|
|
1058
|
+
error.code = 'EACCES';
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
return (originalReadFile as any)(filePath, ...rest);
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
const auth = Buffer.from('worker1:secret1').toString('base64');
|
|
1065
|
+
const res = await request('POST', '/worker/job', {}, {
|
|
1066
|
+
authorization: `Basic ${auth}`
|
|
1067
|
+
});
|
|
1068
|
+
// The request should fail loudly rather than silently proceeding as if there
|
|
1069
|
+
// were no claimed job.
|
|
1070
|
+
assert.equal(res.statusCode, 500);
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
t.mock.reset();
|
|
1074
|
+
await fs.unlink(claimedJobPath).catch(() => {});
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
905
1078
|
test('POST /worker/job with a queued job assigns it to the worker', {timeout: 500}, async () => {
|
|
906
1079
|
// Clean up claimed and queue directories.
|
|
907
1080
|
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
@@ -1069,11 +1242,12 @@ const htmlPagePaths = [
|
|
|
1069
1242
|
'/reannotateForm.html',
|
|
1070
1243
|
'/renewWCAGForm.html',
|
|
1071
1244
|
'/hideReportForm.html',
|
|
1072
|
-
'/
|
|
1245
|
+
'/showHiddenReportsForm.html',
|
|
1073
1246
|
'/expungeReportsForm.html',
|
|
1074
1247
|
'/pruneReportsForm.html',
|
|
1075
1248
|
'/rewindReportsForm.html',
|
|
1076
|
-
'/ai0BalanceForm.html'
|
|
1249
|
+
'/ai0BalanceForm.html',
|
|
1250
|
+
'/metrics.html'
|
|
1077
1251
|
];
|
|
1078
1252
|
|
|
1079
1253
|
for (const pagePath of htmlPagePaths) {
|
|
@@ -1084,6 +1258,187 @@ for (const pagePath of htmlPagePaths) {
|
|
|
1084
1258
|
});
|
|
1085
1259
|
}
|
|
1086
1260
|
|
|
1261
|
+
test('POST /metrics.html with a valid authCode serves the usage-metrics table and records a managerActivity success', async () => {
|
|
1262
|
+
const countBefore = await getManagerActivityCount('metrics', 'ok');
|
|
1263
|
+
const res = await formRequest('POST', '/metrics.html', {authCode: 'test-auth-code'});
|
|
1264
|
+
assert.equal(res.statusCode, 200);
|
|
1265
|
+
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
1266
|
+
assert.equal(await getManagerActivityCount('metrics', 'ok'), countBefore + 1);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test('POST /metrics.html with a valid authCode sets a metrics-exclusion cookie', async () => {
|
|
1270
|
+
const res = await formRequest('POST', '/metrics.html', {authCode: 'test-auth-code'});
|
|
1271
|
+
const setCookie = res.headers['set-cookie']?.[0] ?? res.headers['set-cookie'];
|
|
1272
|
+
assert.ok(setCookie);
|
|
1273
|
+
assert.ok(setCookie.startsWith('kilotestExclude='));
|
|
1274
|
+
assert.ok(setCookie.includes('Max-Age=2592000'));
|
|
1275
|
+
assert.ok(setCookie.includes('HttpOnly'));
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test('GET /metrics.html with a valid authCode in the query string does not set a cookie or show the counts', async () => {
|
|
1279
|
+
const res = await request('GET', '/metrics.html?authCode=test-auth-code');
|
|
1280
|
+
assert.equal(res.statusCode, 200);
|
|
1281
|
+
assert.equal(res.headers['set-cookie'], undefined);
|
|
1282
|
+
assert.ok(res.body.includes('Authorization code'));
|
|
1283
|
+
assert.ok(!res.body.includes('Web page views'));
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test('a request carrying the metrics-exclusion cookie does not record a pageViews or apiOperations metric', async () => {
|
|
1287
|
+
const {getExclusionCookieValue} = await import('./util.ts');
|
|
1288
|
+
const cookieHeader = `kilotestExclude=${getExclusionCookieValue()}`;
|
|
1289
|
+
const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
|
|
1290
|
+
const apiOperationCountBefore = await getMetricCount('apiOperations', 'listReports');
|
|
1291
|
+
await request('GET', '/tutorialWeb.html', null, {cookie: cookieHeader});
|
|
1292
|
+
await request('GET', '/api/listReports', null, {cookie: cookieHeader});
|
|
1293
|
+
assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore);
|
|
1294
|
+
assert.equal(await getMetricCount('apiOperations', 'listReports'), apiOperationCountBefore);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test('a request carrying an invalid exclusion cookie still records metrics normally', async () => {
|
|
1298
|
+
const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
|
|
1299
|
+
await request('GET', '/tutorialWeb.html', null, {cookie: 'kilotestExclude=wrong-value'});
|
|
1300
|
+
assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore + 1);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test('a request with an unrelated cookie still records metrics normally', async () => {
|
|
1304
|
+
const pageViewCountBefore = await getMetricCount('pageViews', 'tutorialWeb');
|
|
1305
|
+
await request('GET', '/tutorialWeb.html', null, {cookie: 'someOtherCookie=1'});
|
|
1306
|
+
assert.equal(await getMetricCount('pageViews', 'tutorialWeb'), pageViewCountBefore + 1);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test('GET / and GET /index.html record a pageViews metric under "index"', async () => {
|
|
1310
|
+
const countBefore = await getMetricCount('pageViews', 'index');
|
|
1311
|
+
await request('GET', '/');
|
|
1312
|
+
await request('GET', '/index.html');
|
|
1313
|
+
assert.equal(await getMetricCount('pageViews', 'index'), countBefore + 2);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test('POST /metrics.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
|
|
1317
|
+
const countBefore = await getManagerActivityCount('metrics', 'error');
|
|
1318
|
+
const res = await formRequest('POST', '/metrics.html', {authCode: 'wrong'});
|
|
1319
|
+
assert.equal(res.statusCode, 400);
|
|
1320
|
+
assert.equal(await getManagerActivityCount('metrics', 'error'), countBefore + 1);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test('GET /hideReportForm.html records managerActivity, not a pageViews entry', async () => {
|
|
1324
|
+
const managerCountBefore = await getManagerActivityCount('hideReportForm', 'ok');
|
|
1325
|
+
const pageViewCountBefore = await getMetricCount('pageViews', 'hideReportForm');
|
|
1326
|
+
const res = await request('GET', '/hideReportForm.html');
|
|
1327
|
+
assert.equal(res.statusCode, 200);
|
|
1328
|
+
assert.equal(await getManagerActivityCount('hideReportForm', 'ok'), managerCountBefore + 1);
|
|
1329
|
+
assert.equal(await getMetricCount('pageViews', 'hideReportForm'), pageViewCountBefore);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// TESTS: hidden report list is never served on a direct GET, only via showHiddenReports
|
|
1333
|
+
|
|
1334
|
+
test('GET /unhideReportForm.html is rejected, since the hidden-report list must not be disclosed without an authorization code', async () => {
|
|
1335
|
+
const res = await request('GET', '/unhideReportForm.html');
|
|
1336
|
+
assert.equal(res.statusCode, 400);
|
|
1337
|
+
assert.ok(!res.body.includes('260101T0007-hid'));
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
test('GET /showHiddenReportsForm.html displays a bare authCode form, disclosing no report names', async () => {
|
|
1341
|
+
const res = await request('GET', '/showHiddenReportsForm.html');
|
|
1342
|
+
assert.equal(res.statusCode, 200);
|
|
1343
|
+
assert.ok(res.body.includes('Authorization code'));
|
|
1344
|
+
assert.ok(!res.body.includes('260101T0007-hid'));
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
test('POST /showHiddenReportsForm.html with a valid authCode serves the hidden-report list and records a managerActivity success', async () => {
|
|
1348
|
+
const countBefore = await getManagerActivityCount('showHiddenReportsForm', 'ok');
|
|
1349
|
+
const res = await formRequest('POST', '/showHiddenReportsForm.html', {authCode: 'test-auth-code'});
|
|
1350
|
+
assert.equal(res.statusCode, 200);
|
|
1351
|
+
assert.ok(res.body.includes('type="radio"'));
|
|
1352
|
+
assert.equal(await getManagerActivityCount('showHiddenReportsForm', 'ok'), countBefore + 1);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
test('POST /showHiddenReportsForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
|
|
1356
|
+
const countBefore = await getManagerActivityCount('showHiddenReportsForm', 'error');
|
|
1357
|
+
const res = await formRequest('POST', '/showHiddenReportsForm.html', {authCode: 'wrong'});
|
|
1358
|
+
assert.equal(res.statusCode, 400);
|
|
1359
|
+
assert.equal(await getManagerActivityCount('showHiddenReportsForm', 'error'), countBefore + 1);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// TESTS: self-submitting manager pages now POST their own submissions (formerly GET)
|
|
1363
|
+
|
|
1364
|
+
test('POST /ai0BalanceForm.html with a valid authCode records the balance and records a managerActivity success', async () => {
|
|
1365
|
+
const countBefore = await getManagerActivityCount('ai0BalanceForm', 'ok');
|
|
1366
|
+
const res = await formRequest('POST', '/ai0BalanceForm.html', {
|
|
1367
|
+
newBalance: '42.50',
|
|
1368
|
+
authCode: 'test-auth-code'
|
|
1369
|
+
});
|
|
1370
|
+
assert.equal(res.statusCode, 200);
|
|
1371
|
+
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
1372
|
+
assert.ok(res.body.includes('$42.5'));
|
|
1373
|
+
assert.equal(await getManagerActivityCount('ai0BalanceForm', 'ok'), countBefore + 1);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
test('POST /ai0BalanceForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
|
|
1377
|
+
const countBefore = await getManagerActivityCount('ai0BalanceForm', 'error');
|
|
1378
|
+
const res = await formRequest('POST', '/ai0BalanceForm.html', {
|
|
1379
|
+
newBalance: '10',
|
|
1380
|
+
authCode: 'wrong-code'
|
|
1381
|
+
});
|
|
1382
|
+
assert.equal(res.statusCode, 400);
|
|
1383
|
+
assert.equal(await getManagerActivityCount('ai0BalanceForm', 'error'), countBefore + 1);
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test('POST /hideReportForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
|
|
1387
|
+
const countBefore = await getManagerActivityCount('hideReportForm', 'error');
|
|
1388
|
+
const res = await formRequest('POST', '/hideReportForm.html', {
|
|
1389
|
+
report: '260101T0009-brd',
|
|
1390
|
+
authCode: 'wrong-code'
|
|
1391
|
+
});
|
|
1392
|
+
assert.equal(res.statusCode, 400);
|
|
1393
|
+
assert.equal(await getManagerActivityCount('hideReportForm', 'error'), countBefore + 1);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test('POST /unhideReportForm.html with an invalid authCode is rejected and records a managerActivity failure', async () => {
|
|
1397
|
+
const countBefore = await getManagerActivityCount('unhideReportForm', 'error');
|
|
1398
|
+
const res = await formRequest('POST', '/unhideReportForm.html', {
|
|
1399
|
+
report: '260101T0009-brd',
|
|
1400
|
+
authCode: 'wrong-code'
|
|
1401
|
+
});
|
|
1402
|
+
assert.equal(res.statusCode, 400);
|
|
1403
|
+
assert.equal(await getManagerActivityCount('unhideReportForm', 'error'), countBefore + 1);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test('POST /expungeReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
|
|
1407
|
+
const countBefore = await getManagerActivityCount('expungeReportsForm', 'ok');
|
|
1408
|
+
const res = await formRequest('POST', '/expungeReportsForm.html', {});
|
|
1409
|
+
assert.equal(res.statusCode, 200);
|
|
1410
|
+
assert.equal(await getManagerActivityCount('expungeReportsForm', 'ok'), countBefore + 1);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
test('POST /pruneReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
|
|
1414
|
+
const countBefore = await getManagerActivityCount('pruneReportsForm', 'ok');
|
|
1415
|
+
const res = await formRequest('POST', '/pruneReportsForm.html', {});
|
|
1416
|
+
assert.equal(res.statusCode, 200);
|
|
1417
|
+
assert.equal(await getManagerActivityCount('pruneReportsForm', 'ok'), countBefore + 1);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test('POST /rewindReportsForm.html with no selections shows the form and records a managerActivity success', async () => {
|
|
1421
|
+
const countBefore = await getManagerActivityCount('rewindReportsForm', 'ok');
|
|
1422
|
+
const res = await formRequest('POST', '/rewindReportsForm.html', {});
|
|
1423
|
+
assert.equal(res.statusCode, 200);
|
|
1424
|
+
assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
|
|
1428
|
+
const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
|
|
1429
|
+
const backup = await fs.readFile(reportPath, 'utf8');
|
|
1430
|
+
const countBefore = await getManagerActivityCount('pruneReportsForm', 'error');
|
|
1431
|
+
try {
|
|
1432
|
+
await fs.writeFile(reportPath, 'not valid json');
|
|
1433
|
+
const res = await request('GET', '/pruneReportsForm.html');
|
|
1434
|
+
assert.equal(res.statusCode, 400);
|
|
1435
|
+
assert.equal(await getManagerActivityCount('pruneReportsForm', 'error'), countBefore + 1);
|
|
1436
|
+
}
|
|
1437
|
+
finally {
|
|
1438
|
+
await fs.writeFile(reportPath, backup);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1087
1442
|
test('GET /enqueueForm.html shows requests when testRequests.json has entries', async () => {
|
|
1088
1443
|
await fs.writeFile(testRequestsPath, JSON.stringify({
|
|
1089
1444
|
'https://example.com/enqueue-test': [
|
|
@@ -1224,7 +1579,7 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
|
|
|
1224
1579
|
assert.ok(res.body.includes('Test request duplicates an already submitted request'));
|
|
1225
1580
|
});
|
|
1226
1581
|
|
|
1227
|
-
test('POST /requestAction.html with valid auth code
|
|
1582
|
+
test('POST /requestAction.html with valid auth code approves the same target twice without error', {timeout: 500}, async () => {
|
|
1228
1583
|
await fs.writeFile(testRequestsPath, '{}\n');
|
|
1229
1584
|
// Create a request.
|
|
1230
1585
|
await formRequest('POST', '/requestTest.html', {
|
|
@@ -1238,27 +1593,26 @@ test('POST /requestAction.html with valid auth code and approval of a duplicate
|
|
|
1238
1593
|
authCode: 'test-auth-code',
|
|
1239
1594
|
what: 'yes'
|
|
1240
1595
|
});
|
|
1241
|
-
// Approve it again
|
|
1242
|
-
//
|
|
1596
|
+
// Approve it again. enqueue.answer has no duplicate-detection logic of its own (that
|
|
1597
|
+
// lives in processTestRequest, upstream of approval), so a second approval of the same
|
|
1598
|
+
// target queues a second job rather than erroring.
|
|
1243
1599
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1244
1600
|
target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
|
|
1245
1601
|
authCode: 'test-auth-code',
|
|
1246
1602
|
what: 'yes'
|
|
1247
1603
|
});
|
|
1248
|
-
|
|
1249
|
-
assert.ok(res.statusCode === 200 || res.statusCode === 400);
|
|
1604
|
+
assert.equal(res.statusCode, 200);
|
|
1250
1605
|
});
|
|
1251
1606
|
|
|
1252
1607
|
test('POST /requestAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
|
|
1253
|
-
// A URL that starts with https:// but is not a valid URL
|
|
1254
|
-
// enqueue.answer to return status error.
|
|
1608
|
+
// A URL that starts with https:// but is not a valid URL fails index.ts's own isURL check.
|
|
1255
1609
|
const res = await formRequest('POST', '/requestAction.html', {
|
|
1256
1610
|
target: 'https://\tTest Page',
|
|
1257
1611
|
authCode: 'test-auth-code',
|
|
1258
1612
|
what: 'yes'
|
|
1259
1613
|
});
|
|
1260
1614
|
assert.equal(res.statusCode, 400);
|
|
1261
|
-
assert.ok(res.body.includes('Invalid
|
|
1615
|
+
assert.ok(res.body.includes('Invalid request'));
|
|
1262
1616
|
});
|
|
1263
1617
|
|
|
1264
1618
|
// UNIT TESTS FOR serveError
|
|
@@ -1325,6 +1679,75 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
|
|
|
1325
1679
|
assert.equal(result.reason, 'test reason');
|
|
1326
1680
|
});
|
|
1327
1681
|
|
|
1682
|
+
// TESTS FOR general per-request logging
|
|
1683
|
+
|
|
1684
|
+
test('a request logs a single-line JSON record with the expected fields', async () => {
|
|
1685
|
+
const logged: string[] = [];
|
|
1686
|
+
const originalLog = console.log;
|
|
1687
|
+
console.log = (line: string) => logged.push(line);
|
|
1688
|
+
try {
|
|
1689
|
+
await request('GET', '/listReports.html');
|
|
1690
|
+
}
|
|
1691
|
+
finally {
|
|
1692
|
+
console.log = originalLog;
|
|
1693
|
+
}
|
|
1694
|
+
const requestLines = logged.filter(line => {
|
|
1695
|
+
try {
|
|
1696
|
+
return JSON.parse(line).type === 'request';
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
assert.equal(requestLines.length, 1);
|
|
1703
|
+
const record = JSON.parse(requestLines[0]!);
|
|
1704
|
+
assert.equal(record.method, 'GET');
|
|
1705
|
+
assert.equal(record.path, '/listReports.html');
|
|
1706
|
+
assert.equal(record.status, 200);
|
|
1707
|
+
assert.ok(record.ip);
|
|
1708
|
+
assert.ok(record.userAgent);
|
|
1709
|
+
assert.ok(record.time);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
test('a request logs its path without its query string', async () => {
|
|
1713
|
+
const logged: string[] = [];
|
|
1714
|
+
const originalLog = console.log;
|
|
1715
|
+
console.log = (line: string) => logged.push(line);
|
|
1716
|
+
try {
|
|
1717
|
+
await request('GET', '/listIssues.html/260101T0000/mix?authCode=secret');
|
|
1718
|
+
}
|
|
1719
|
+
finally {
|
|
1720
|
+
console.log = originalLog;
|
|
1721
|
+
}
|
|
1722
|
+
const record = logged
|
|
1723
|
+
.map(line => { try {return JSON.parse(line);} catch {return null;} })
|
|
1724
|
+
.find(parsed => parsed?.type === 'request');
|
|
1725
|
+
assert.ok(record);
|
|
1726
|
+
assert.equal(record.path, '/listIssues.html/260101T0000/mix');
|
|
1727
|
+
assert.ok(!JSON.stringify(record).includes('secret'));
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
test('a smoke-test-flagged request is not logged as a general request', async () => {
|
|
1731
|
+
const logged: string[] = [];
|
|
1732
|
+
const originalLog = console.log;
|
|
1733
|
+
console.log = (line: string) => logged.push(line);
|
|
1734
|
+
try {
|
|
1735
|
+
await request('GET', '/listReports.html', null, {'x-kilotest-smoke': '1'});
|
|
1736
|
+
}
|
|
1737
|
+
finally {
|
|
1738
|
+
console.log = originalLog;
|
|
1739
|
+
}
|
|
1740
|
+
const requestLines = logged.filter(line => {
|
|
1741
|
+
try {
|
|
1742
|
+
return JSON.parse(line).type === 'request';
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
assert.equal(requestLines.length, 0);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1328
1751
|
// UNIT TESTS FOR startServer
|
|
1329
1752
|
|
|
1330
1753
|
test('startServer starts an HTTP server when protocol is http', {timeout: 500}, async () => {
|