@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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/getReport.ts +3 -1
  3. package/api/requestFeature.test.ts +23 -3
  4. package/api/requestFeature.ts +19 -11
  5. package/api/requestRetest.test.ts +39 -13
  6. package/api/requestRetest.ts +11 -5
  7. package/api/requestTest.ts +6 -6
  8. package/api/schemas.ts +2 -2
  9. package/docs/BACKLOG.md +232 -12
  10. package/docs/SERVICE.md +84 -3
  11. package/index.test.ts +443 -20
  12. package/index.ts +246 -27
  13. package/linkValidation.test.ts +1 -0
  14. package/mcp.test.ts +31 -1
  15. package/mcp.ts +9 -0
  16. package/package.json +1 -1
  17. package/smokeTest.ts +16 -0
  18. package/test/dbFixture.ts +8 -0
  19. package/util.test.ts +295 -0
  20. package/util.ts +121 -0
  21. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  22. package/web/ai0BalanceForm/index.html +1 -1
  23. package/web/ai0BalanceForm/index.ts +12 -8
  24. package/web/enqueue/index.ts +33 -39
  25. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  26. package/web/expungeReportsForm/index.html +1 -1
  27. package/web/expungeReportsForm/index.ts +1 -1
  28. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  29. package/web/hideReportForm/index.html +1 -1
  30. package/web/hideReportForm/index.ts +13 -8
  31. package/web/listTopIssues/index.ts +1 -1
  32. package/web/manage/index.html +2 -1
  33. package/web/metrics/index.html +19 -0
  34. package/web/metrics/index.ts +127 -0
  35. package/web/metrics/metrics.test.ts +211 -0
  36. package/web/pruneReportsForm/index.html +1 -1
  37. package/web/pruneReportsForm/index.ts +1 -1
  38. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  39. package/web/reannotate/index.ts +7 -3
  40. package/web/reannotate/reannotate.test.ts +1 -1
  41. package/web/renewWCAG/index.ts +6 -3
  42. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  43. package/web/reportDeletion.ts +13 -7
  44. package/web/rewindReportsForm/index.html +1 -1
  45. package/web/rewindReportsForm/index.ts +1 -1
  46. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  47. package/web/showHiddenReportsForm/index.html +24 -0
  48. package/web/showHiddenReportsForm/index.ts +45 -0
  49. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  50. package/web/tutorialAI/index.ts +1 -1
  51. package/web/tutorialWeb/index.ts +1 -1
  52. package/web/unhideReportForm/index.html +1 -1
  53. package/web/unhideReportForm/index.ts +13 -8
  54. 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 test order'));
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
- '/unhideReportForm.html',
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 and approval of a duplicate returns an error', {timeout: 500}, async () => {
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 (the requests may have been cleared, so this may succeed or fail).
1242
- // This test covers the requestAction answer error branch.
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
- // Either it succeeds (200) or returns an error (400).
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 causes
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 authorization code'));
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 () => {